webspresso 0.0.102 → 0.0.104

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.
Files changed (91) hide show
  1. package/.agents/ORM.md +13 -4
  2. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  3. package/README.md +2 -2
  4. package/bin/commands/dev.js +3 -2
  5. package/bin/utils/orm-map-html.js +2 -8
  6. package/core/auth/jwt.js +12 -21
  7. package/core/auth/oauth.js +38 -14
  8. package/core/auth/totp.js +3 -15
  9. package/core/auth/user-agent.js +59 -60
  10. package/core/compression/compression-stream.js +2 -2
  11. package/core/compression/middleware.js +1 -1
  12. package/core/compression/negotiate.js +11 -4
  13. package/core/content/renderer.js +1 -13
  14. package/core/errors/middleware.js +2 -8
  15. package/core/events/dispatcher.js +56 -33
  16. package/core/forms/form-builder.js +1 -9
  17. package/core/openapi/orm-components.js +2 -3
  18. package/core/orm/cache/listeners.js +12 -2
  19. package/core/orm/index.js +5 -0
  20. package/core/orm/json-fields.js +7 -1
  21. package/core/orm/model.js +99 -8
  22. package/core/orm/query-builder.js +71 -25
  23. package/core/orm/query-filters.js +393 -0
  24. package/core/orm/repository.js +42 -17
  25. package/core/orm/schema-helpers.js +49 -0
  26. package/core/orm/scopes.js +40 -9
  27. package/core/orm/types.js +12 -1
  28. package/core/queue/manager.js +5 -0
  29. package/core/shutdown/shutdown-manager.js +16 -16
  30. package/core/utils/crypto.js +73 -0
  31. package/core/utils/escape-html.js +7 -0
  32. package/core/utils/fetch.js +120 -0
  33. package/core/utils/get-client-ip.js +6 -0
  34. package/core/utils/html.js +72 -0
  35. package/core/utils/ip.js +86 -0
  36. package/core/utils/safe-compare.js +3 -0
  37. package/core/validation/index.js +9 -3
  38. package/index.d.ts +33 -6
  39. package/index.js +10 -0
  40. package/package.json +2 -1
  41. package/plugins/admin-panel/auth.js +15 -7
  42. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  43. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  44. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  45. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  46. package/plugins/admin-panel/core/api-extensions.js +1 -75
  47. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  48. package/plugins/api-key/crypto.js +1 -24
  49. package/plugins/audit-log/api-handlers.js +11 -1
  50. package/plugins/audit-log/middleware.js +50 -6
  51. package/plugins/basic-auth/index.js +2 -26
  52. package/plugins/cors.js +2 -2
  53. package/plugins/csrf/index.js +3 -15
  54. package/plugins/email/service.js +32 -1
  55. package/plugins/file-manager/admin-component.js +3 -1
  56. package/plugins/file-manager/api-handlers.js +10 -17
  57. package/plugins/file-manager/core.js +34 -4
  58. package/plugins/file-manager/index.js +11 -9
  59. package/plugins/file-manager/security.js +18 -6
  60. package/plugins/mcp/auth.js +1 -13
  61. package/plugins/mcp/index.js +23 -1
  62. package/plugins/mcp/transports/sse.js +10 -1
  63. package/plugins/orm-cache-admin/admin-component.js +3 -2
  64. package/plugins/polar/src/api.js +9 -2
  65. package/plugins/polar/src/webhooks.js +3 -8
  66. package/plugins/recaptcha/helpers.js +1 -7
  67. package/plugins/recaptcha/verify.js +37 -23
  68. package/plugins/redirect/index.js +51 -3
  69. package/plugins/rest-resources/index.js +1 -207
  70. package/plugins/schema-explorer/admin-component.js +4 -2
  71. package/plugins/schema-explorer.js +40 -29
  72. package/plugins/seo-checker/index.js +5 -2
  73. package/plugins/site-analytics/bot-patterns.js +3 -0
  74. package/plugins/site-analytics/client-error-handler.js +3 -1
  75. package/plugins/site-analytics/index.js +10 -1
  76. package/plugins/site-analytics/tracking.js +265 -63
  77. package/plugins/sitemap.js +18 -15
  78. package/plugins/swagger.js +9 -14
  79. package/plugins/webhooks/crypto.js +1 -18
  80. package/plugins/webhooks/dispatcher.js +6 -1
  81. package/plugins/webhooks/matcher.js +25 -10
  82. package/plugins/webhooks/sender.js +32 -2
  83. package/src/api/api-loader.js +1 -1
  84. package/src/dev-reload/index.js +47 -6
  85. package/src/discovery/scan-routes.js +14 -4
  86. package/src/file-router.js +62 -6
  87. package/src/helpers.js +157 -32
  88. package/src/plugin-manager.js +34 -13
  89. package/src/routing/route-table.js +14 -4
  90. package/src/server.js +25 -9
  91. package/src/services/validator.js +57 -32
package/.agents/ORM.md CHANGED
@@ -51,7 +51,9 @@ import { defineModel, zdb } from 'webspresso';
51
51
  export default defineModel({
52
52
  name: 'Product',
53
53
  table: 'products',
54
- schema: zdb.schema({
54
+ timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
55
+ softDelete: true, // auto-injects deletedAt & manages soft-delete scope
56
+ schema: {
55
57
  id: zdb.id(),
56
58
  name: zdb.string().min(2),
57
59
  slug: zdb.string().unique(),
@@ -61,9 +63,7 @@ export default defineModel({
61
63
  metadata: zdb.json({ optional: true }),
62
64
  category_id: zdb.integer(),
63
65
  active: zdb.boolean({ default: true }),
64
- created_at: zdb.timestamp({ auto: 'create' }),
65
- updated_at: zdb.timestamp({ auto: 'update' }),
66
- }),
66
+ },
67
67
  relations: {
68
68
  category: { type: 'belongsTo', model: 'Category', foreignKey: 'category_id' },
69
69
  reviews: { type: 'hasMany', model: 'Review', foreignKey: 'product_id' },
@@ -81,6 +81,13 @@ export default defineModel({
81
81
  });
82
82
  ```
83
83
 
84
+ ### Automatic Timestamps & Soft Deletes (`timestamps`, `softDelete`)
85
+ Instead of manually defining boilerplate timestamp and soft delete columns in `schema`, configure them declaratively:
86
+ - **Top-level / Options:** `timestamps: true` (or `options: { timestamps: true }`) automatically injects `createdAt` and `updatedAt` into the Zod schema, validation rules, column map, and Knex migrations.
87
+ - **Soft Deletes:** `softDelete: true` (or `softDeletes: true`) automatically injects `deletedAt` (nullable/optional) into the schema and activates the repository `whereNull('deletedAt')` soft-delete scope.
88
+ - **Custom column names & snake_case:** `timestamps: { snakeCase: true }` or `timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }`, `softDelete: { deletedAt: 'deleted_at' }`.
89
+ - **Explicit Schema Spread:** `...zdb.timestamps()` and `...zdb.softDeletes()` are also supported for explicit schema definitions.
90
+
84
91
  ### Supported `zdb` Data Types
85
92
  - `zdb.id()` — Primary key (BigInt / Auto-increment)
86
93
  - `zdb.string({ max, unique, optional, sortable })` — String / VarChar
@@ -90,6 +97,8 @@ export default defineModel({
90
97
  - `zdb.file()` — File asset URL / upload path (non-sortable by default)
91
98
  - `zdb.json()` — Serialized JSON column (non-sortable by default)
92
99
  - `zdb.timestamp({ auto, sortable })` — Timestamp (`auto: 'create'` / `auto: 'update'`)
100
+ - `zdb.timestamps({ snakeCase })` — Timestamp pair (`createdAt`, `updatedAt`)
101
+ - `zdb.softDelete()`, `zdb.softDeletes({ snakeCase })` — Soft delete timestamp (`deletedAt`)
93
102
 
94
103
  *Note: All `zdb` types support chainable `.config({ sortable: boolean })` or options object `{ sortable: boolean }`.*
95
104
 
@@ -21,8 +21,10 @@ module.exports = defineModel({
21
21
  name: 'Product',
22
22
  table: 'products',
23
23
  primaryKey: 'id',
24
+ timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
25
+ softDelete: true, // auto-injects deletedAt & enables soft-delete scope
24
26
 
25
- schema: zdb.object({
27
+ schema: {
26
28
  id: zdb.id(),
27
29
  name: zdb.string().min(1).max(255),
28
30
  slug: zdb.string().unique(),
@@ -30,10 +32,7 @@ module.exports = defineModel({
30
32
  in_stock: zdb.boolean().default(true),
31
33
  metadata: zdb.json().optional(),
32
34
  user_id: zdb.foreignKey('users.id'),
33
- created_at: zdb.timestamp().autoCreate(),
34
- updated_at: zdb.timestamp().autoUpdate(),
35
- deleted_at: zdb.timestamp().nullable(),
36
- }),
35
+ },
37
36
 
38
37
  relations: {
39
38
  user: {
package/README.md CHANGED
@@ -115,13 +115,13 @@ const { defineModel, zdb } = require('webspresso');
115
115
  module.exports = defineModel({
116
116
  name: 'Note',
117
117
  table: 'notes',
118
+ timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
119
+ softDelete: true, // auto-injects deletedAt & enables soft-delete scope
118
120
  schema: {
119
121
  id: zdb.id(),
120
122
  title: zdb.string(200),
121
123
  content: zdb.text(),
122
124
  userId: zdb.integer(),
123
- ...zdb.timestamps(),
124
- ...zdb.softDeletes(),
125
125
  },
126
126
  relations: {
127
127
  user: { type: 'belongsTo', model: 'User', foreignKey: 'userId' },
@@ -13,8 +13,9 @@ const { spawn } = require('child_process');
13
13
  function buildWatchArgs() {
14
14
  const args = ['--watch'];
15
15
 
16
- // Add watch paths for common directories
17
- const watchPaths = ['pages', 'models', 'views'];
16
+ // Add watch paths for server-side code directories (services, models, config, pages)
17
+ // Note: views contains Nunjucks templates which are live-reloaded in-memory by devReloadHub
18
+ const watchPaths = ['pages', 'models', 'services', 'config'];
18
19
 
19
20
  for (const dir of watchPaths) {
20
21
  if (fs.existsSync(dir)) {
@@ -4,6 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
+ const { escapeHtml } = require('../../core/utils/html');
7
8
 
8
9
  /**
9
10
  * @param {object} snapshot - { generatedAt, models }
@@ -661,14 +662,7 @@ function buildOrmMapHtml(snapshot, mermaidSource, meta = {}) {
661
662
  `;
662
663
  }
663
664
 
664
- function escapeHtml(s) {
665
- if (s == null) return '';
666
- return String(s)
667
- .replace(/&/g, '&')
668
- .replace(/</g, '&lt;')
669
- .replace(/>/g, '&gt;')
670
- .replace(/"/g, '&quot;');
671
- }
665
+
672
666
 
673
667
  /**
674
668
  * Optional: read package name from cwd/package.json
package/core/auth/jwt.js CHANGED
@@ -5,6 +5,9 @@
5
5
  */
6
6
 
7
7
  const crypto = require('crypto');
8
+ const { safeCompare } = require('../utils/crypto');
9
+
10
+ const HS256_HEADER_BASE64URL = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
8
11
 
9
12
  /**
10
13
  * Base64URL encode string or Buffer
@@ -12,12 +15,9 @@ const crypto = require('crypto');
12
15
  * @returns {string}
13
16
  */
14
17
  function base64UrlEncode(input) {
15
- const buf = Buffer.isBuffer(input) ? input : Buffer.from(input, 'utf8');
16
- return buf
17
- .toString('base64')
18
- .replace(/=/g, '')
19
- .replace(/\+/g, '-')
20
- .replace(/\//g, '_');
18
+ return Buffer.isBuffer(input)
19
+ ? input.toString('base64url')
20
+ : Buffer.from(String(input), 'utf8').toString('base64url');
21
21
  }
22
22
 
23
23
  /**
@@ -26,11 +26,7 @@ function base64UrlEncode(input) {
26
26
  * @returns {string}
27
27
  */
28
28
  function base64UrlDecode(input) {
29
- let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
30
- while (base64.length % 4 !== 0) {
31
- base64 += '=';
32
- }
33
- return Buffer.from(base64, 'base64').toString('utf8');
29
+ return Buffer.from(String(input), 'base64url').toString('utf8');
34
30
  }
35
31
 
36
32
  /**
@@ -110,16 +106,14 @@ function signJwt(payload, secret, options = {}) {
110
106
  ...payload,
111
107
  };
112
108
 
113
- const encodedHeader = base64UrlEncode(JSON.stringify(header));
109
+ const encodedHeader = algorithm === 'HS256' ? HS256_HEADER_BASE64URL : base64UrlEncode(JSON.stringify(header));
114
110
  const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
115
111
 
116
112
  const signingInput = `${encodedHeader}.${encodedPayload}`;
117
- const signature = crypto
113
+ const encodedSignature = crypto
118
114
  .createHmac('sha256', secret)
119
115
  .update(signingInput)
120
- .digest();
121
-
122
- const encodedSignature = base64UrlEncode(signature);
116
+ .digest('base64url');
123
117
 
124
118
  return `${signingInput}.${encodedSignature}`;
125
119
  }
@@ -177,12 +171,9 @@ function verifyJwt(token, secret, options = {}) {
177
171
  .update(signingInput)
178
172
  .digest();
179
173
 
180
- const actualSignatureBuf = Buffer.from(encodedSignature.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
174
+ const actualSignatureBuf = Buffer.from(encodedSignature, 'base64url');
181
175
 
182
- if (
183
- expectedSignatureBuf.length !== actualSignatureBuf.length ||
184
- !crypto.timingSafeEqual(expectedSignatureBuf, actualSignatureBuf)
185
- ) {
176
+ if (!safeCompare(expectedSignatureBuf, actualSignatureBuf)) {
186
177
  throw new Error('Invalid JWT signature');
187
178
  }
188
179
 
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  const crypto = require('crypto');
9
+ const { fetchWithTimeout } = require('../utils/fetch');
10
+ const { safeCompare } = require('../utils/crypto');
9
11
 
10
12
  /**
11
13
  * Custom error class for OAuth operations
@@ -57,14 +59,17 @@ const DEFAULT_PROVIDERS = {
57
59
  userEmailsUrl: 'https://api.github.com/user/emails',
58
60
  defaultScopes: ['read:user', 'user:email'],
59
61
  defaultParams: {},
60
- async fetchExtra(tokens) {
62
+ async fetchExtra(tokens, options = {}) {
61
63
  try {
62
- const res = await fetch('https://api.github.com/user/emails', {
64
+ const timeout = typeof options?.timeout === 'number' ? options.timeout : 10000;
65
+ const res = await fetchWithTimeout('https://api.github.com/user/emails', {
63
66
  headers: {
64
67
  Authorization: `Bearer ${tokens.access_token}`,
65
68
  Accept: 'application/vnd.github.v3+json',
66
69
  'User-Agent': 'Webspresso-OAuth',
67
70
  },
71
+ timeout,
72
+ signal: options?.signal,
68
73
  });
69
74
  if (res.ok) {
70
75
  const emails = await res.json();
@@ -78,7 +83,7 @@ const DEFAULT_PROVIDERS = {
78
83
  }
79
84
  }
80
85
  } catch {
81
- // Fallback gracefully if email endpoint is unreachable
86
+ // Fallback gracefully if email endpoint is unreachable or times out
82
87
  }
83
88
  return null;
84
89
  },
@@ -167,10 +172,7 @@ function verifySignedState(stateString, secret, maxAgeMs = 10 * 60 * 1000) {
167
172
  .update(body)
168
173
  .digest('base64url');
169
174
 
170
- const bufA = Buffer.from(signature);
171
- const bufB = Buffer.from(expectedSignature);
172
-
173
- if (bufA.length !== bufB.length || !crypto.timingSafeEqual(bufA, bufB)) {
175
+ if (!safeCompare(signature, expectedSignature)) {
174
176
  throw new OAuthError('OAuth state signature verification failed', 'INVALID_STATE');
175
177
  }
176
178
 
@@ -202,6 +204,9 @@ class OAuthClient {
202
204
  constructor(config = {}) {
203
205
  this.secret = config.secret || process.env.OAUTH_SECRET || process.env.SESSION_SECRET || 'webspresso-oauth-secret';
204
206
  this.stateTtlMs = config.stateTtlMs || 10 * 60 * 1000;
207
+ this.timeout = typeof (config.timeout || config.requestTimeout) === 'number'
208
+ ? (config.timeout || config.requestTimeout)
209
+ : 10000;
205
210
  this.providers = {};
206
211
 
207
212
  if (config.providers) {
@@ -227,6 +232,7 @@ class OAuthClient {
227
232
  redirectUri: options.redirectUri || options.redirect_uri || builtin.redirectUri,
228
233
  scopes: options.scopes || options.scope || builtin.defaultScopes || [],
229
234
  params: { ...(builtin.defaultParams || {}), ...(options.params || {}) },
235
+ timeout: typeof options.timeout === 'number' ? options.timeout : (builtin.timeout || this.timeout || 10000),
230
236
  };
231
237
  return this;
232
238
  }
@@ -330,7 +336,7 @@ class OAuthClient {
330
336
  * @param {string} [params.codeVerifier]
331
337
  * @returns {Promise<Object>} Token response
332
338
  */
333
- async exchangeCode(providerName, { code, redirectUri, codeVerifier }) {
339
+ async exchangeCode(providerName, { code, redirectUri, codeVerifier, timeout, signal } = {}) {
334
340
  const provider = this.getProvider(providerName);
335
341
 
336
342
  if (!code) {
@@ -354,9 +360,13 @@ class OAuthClient {
354
360
  bodyParams.set('code_verifier', codeVerifier);
355
361
  }
356
362
 
363
+ const requestTimeout = typeof timeout === 'number'
364
+ ? timeout
365
+ : (provider.timeout || this.timeout || 10000);
366
+
357
367
  let res;
358
368
  try {
359
- res = await fetch(provider.tokenUrl, {
369
+ res = await fetchWithTimeout(provider.tokenUrl, {
360
370
  method: 'POST',
361
371
  headers: {
362
372
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -364,6 +374,8 @@ class OAuthClient {
364
374
  'User-Agent': 'Webspresso-OAuth',
365
375
  },
366
376
  body: bodyParams.toString(),
377
+ timeout: requestTimeout,
378
+ signal,
367
379
  });
368
380
  } catch (err) {
369
381
  throw new OAuthError(`Token exchange network request failed: ${err.message}`, 'NETWORK_ERROR', err);
@@ -398,7 +410,7 @@ class OAuthClient {
398
410
  * @param {Object} tokens - Tokens object returned from exchangeCode
399
411
  * @returns {Promise<Object>} Normalized user profile
400
412
  */
401
- async fetchUserProfile(providerName, tokens) {
413
+ async fetchUserProfile(providerName, tokens, options = {}) {
402
414
  const provider = this.getProvider(providerName);
403
415
  const accessToken = tokens.access_token || tokens.accessToken;
404
416
 
@@ -406,19 +418,26 @@ class OAuthClient {
406
418
  throw new OAuthError('Missing access_token for user profile retrieval', 'MISSING_TOKEN');
407
419
  }
408
420
 
421
+ const requestTimeout = typeof options?.timeout === 'number'
422
+ ? options.timeout
423
+ : (provider.timeout || this.timeout || 10000);
424
+ const signal = options?.signal;
425
+
409
426
  let extraData = null;
410
427
  if (typeof provider.fetchExtra === 'function') {
411
- extraData = await provider.fetchExtra(tokens);
428
+ extraData = await provider.fetchExtra(tokens, { timeout: requestTimeout, signal });
412
429
  }
413
430
 
414
431
  let res;
415
432
  try {
416
- res = await fetch(provider.userInfoUrl, {
433
+ res = await fetchWithTimeout(provider.userInfoUrl, {
417
434
  headers: {
418
435
  Authorization: `Bearer ${accessToken}`,
419
436
  Accept: 'application/json',
420
437
  'User-Agent': 'Webspresso-OAuth',
421
438
  },
439
+ timeout: requestTimeout,
440
+ signal,
422
441
  });
423
442
  } catch (err) {
424
443
  throw new OAuthError(`User profile fetch failed: ${err.message}`, 'NETWORK_ERROR', err);
@@ -457,7 +476,7 @@ class OAuthClient {
457
476
  * @param {string} [params.redirectUri] - Redirect URI
458
477
  * @returns {Promise<{ provider: string, user: Object, tokens: Object, stateData: Object }>}
459
478
  */
460
- async handleCallback(providerName, { code, state, redirectUri }) {
479
+ async handleCallback(providerName, { code, state, redirectUri, timeout, signal } = {}) {
461
480
  if (!state) {
462
481
  throw new OAuthError('Missing OAuth state in callback', 'INVALID_STATE');
463
482
  }
@@ -483,10 +502,15 @@ class OAuthClient {
483
502
  code,
484
503
  redirectUri: effectiveRedirectUri,
485
504
  codeVerifier,
505
+ timeout,
506
+ signal,
486
507
  });
487
508
 
488
509
  // Fetch normalized profile
489
- const user = await this.fetchUserProfile(providerName, tokens);
510
+ const user = await this.fetchUserProfile(providerName, tokens, {
511
+ timeout,
512
+ signal,
513
+ });
490
514
 
491
515
  return {
492
516
  provider: providerName,
package/core/auth/totp.js CHANGED
@@ -163,20 +163,8 @@ function generateHotp(secret, counter, options = {}) {
163
163
  return token;
164
164
  }
165
165
 
166
- /**
167
- * Timing-safe token equality check
168
- * @param {string} a
169
- * @param {string} b
170
- * @returns {boolean}
171
- */
172
- function timingSafeTokenCompare(a, b) {
173
- if (typeof a !== 'string' || typeof b !== 'string') return false;
174
- if (a.length !== b.length) return false;
175
-
176
- const bufA = Buffer.from(a);
177
- const bufB = Buffer.from(b);
178
- return crypto.timingSafeEqual(bufA, bufB);
179
- }
166
+ const { safeCompare } = require('../utils/crypto');
167
+ const timingSafeTokenCompare = safeCompare;
180
168
 
181
169
  /**
182
170
  * Verify HOTP token against counter
@@ -412,7 +400,7 @@ function verifyRecoveryCode(code, hashedCodes = []) {
412
400
  continue;
413
401
  }
414
402
  const candidateBuf = Buffer.from(candidateHash, 'hex');
415
- if (crypto.timingSafeEqual(targetBuf, candidateBuf)) {
403
+ if (safeCompare(targetBuf, candidateBuf)) {
416
404
  matchedIndex = i;
417
405
  break;
418
406
  }
@@ -17,27 +17,29 @@
17
17
  * @property {boolean} isBot
18
18
  */
19
19
 
20
+ const { getClientIp } = require('../utils/ip');
21
+
20
22
  /**
21
- * Extract client IP address handling proxies
22
- * @param {Object} req - Express / Node.js HTTP request
23
- * @returns {string}
23
+ * Parse raw User-Agent header string into structured device info
24
+ * Zero external dependencies — pure regex extraction
25
+ *
26
+ * @param {string} [userAgent='']
27
+ * @returns {DeviceInfo}
24
28
  */
25
- function getClientIp(req) {
26
- if (!req) return '127.0.0.1';
27
-
28
- const forwarded = req.headers?.['x-forwarded-for'];
29
- if (forwarded) {
30
- const ips = typeof forwarded === 'string' ? forwarded.split(',') : forwarded;
31
- const first = Array.isArray(ips) ? ips[0] : ips;
32
- if (first) return first.trim();
33
- }
29
+ const MAX_UA_CACHE = 512;
30
+ const uaCache = new Map();
34
31
 
35
- return req.headers?.['x-real-ip'] ||
36
- req.ip ||
37
- req.connection?.remoteAddress ||
38
- req.socket?.remoteAddress ||
39
- '127.0.0.1';
40
- }
32
+ const EMPTY_DEVICE_INFO = Object.freeze({
33
+ browser: 'Unknown',
34
+ os: 'Unknown',
35
+ device: 'unknown',
36
+ isMobile: false,
37
+ isTablet: false,
38
+ isDesktop: false,
39
+ isBot: false,
40
+ });
41
+
42
+ const BOT_PATTERN = /googlebot|bingbot|yandex|baiduspider|duckduckbot|slurp|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/|slackbot|vkshare|w3c_validator|whatsapp|curl|wget|postman/i;
41
43
 
42
44
  /**
43
45
  * Parse raw User-Agent header string into structured device info
@@ -50,34 +52,30 @@ function parseUserAgent(userAgent = '') {
50
52
  const ua = String(userAgent || '').trim();
51
53
 
52
54
  if (!ua) {
53
- return {
54
- browser: 'Unknown',
55
- os: 'Unknown',
56
- device: 'unknown',
57
- isMobile: false,
58
- isTablet: false,
59
- isDesktop: false,
60
- isBot: false,
61
- };
55
+ return { ...EMPTY_DEVICE_INFO };
56
+ }
57
+
58
+ const cached = uaCache.get(ua);
59
+ if (cached) {
60
+ return { ...cached };
62
61
  }
63
62
 
64
63
  // 1. Detect Bots / Crawlers
65
- const botPattern = /googlebot|bingbot|yandex|baiduspider|duckduckbot|slurp|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/|slackbot|vkshare|w3c_validator|whatsapp|curl|wget|postman/i;
66
- const isBot = botPattern.test(ua);
64
+ const isBot = BOT_PATTERN.test(ua);
67
65
 
68
66
  // 2. Detect Operating System
69
67
  let os = 'Unknown';
68
+ let match;
70
69
  if (/iPad/i.test(ua)) {
71
- const match = ua.match(/OS ([\d_\.]+)/i);
70
+ match = ua.match(/OS ([\d_\.]+)/i);
72
71
  os = match ? `iPadOS ${match[1].replace(/_/g, '.')}` : 'iPadOS';
73
72
  } else if (/iPhone|iPod/i.test(ua)) {
74
- const match = ua.match(/OS ([\d_\.]+)/i);
73
+ match = ua.match(/OS ([\d_\.]+)/i);
75
74
  os = match ? `iOS ${match[1].replace(/_/g, '.')}` : 'iOS';
76
- } else if (/Android/i.test(ua)) {
77
- const match = ua.match(/Android (\d+(\.\d+)?)/i);
78
- os = match ? `Android ${match[1]}` : 'Android';
75
+ } else if ((match = ua.match(/Android (\d+(\.\d+)?)/i))) {
76
+ os = `Android ${match[1]}`;
79
77
  } else if (/Macintosh|Mac OS X/i.test(ua)) {
80
- const match = ua.match(/Mac OS X ([\d_\.]+)/i);
78
+ match = ua.match(/Mac OS X ([\d_\.]+)/i);
81
79
  if (match) {
82
80
  const ver = match[1].replace(/_/g, '.');
83
81
  os = `macOS ${ver}`;
@@ -100,30 +98,22 @@ function parseUserAgent(userAgent = '') {
100
98
 
101
99
  // 3. Detect Browser
102
100
  let browser = 'Unknown';
103
- if (/Edg(e|A|iOS)?\/(\d+)/i.test(ua)) {
104
- const match = ua.match(/Edg(e|A|iOS)?\/(\d+)/i);
105
- browser = `Edge ${match ? match[2] : ''}`.trim();
106
- } else if (/OPR\/(\d+)/i.test(ua)) {
107
- const match = ua.match(/OPR\/(\d+)/i);
108
- browser = `Opera ${match ? match[1] : ''}`.trim();
109
- } else if (/SamsungBrowser\/(\d+)/i.test(ua)) {
110
- const match = ua.match(/SamsungBrowser\/(\d+)/i);
111
- browser = `Samsung Internet ${match ? match[1] : ''}`.trim();
112
- } else if (/Brave\/(\d+)/i.test(ua)) {
113
- const match = ua.match(/Brave\/(\d+)/i);
114
- browser = `Brave ${match ? match[1] : ''}`.trim();
115
- } else if (/Vivaldi\/(\d+)/i.test(ua)) {
116
- const match = ua.match(/Vivaldi\/(\d+)/i);
117
- browser = `Vivaldi ${match ? match[1] : ''}`.trim();
118
- } else if (/Chrome\/(\d+)/i.test(ua) && !/Chromium/i.test(ua)) {
119
- const match = ua.match(/Chrome\/(\d+)/i);
120
- browser = `Chrome ${match ? match[1] : ''}`.trim();
121
- } else if (/Firefox\/(\d+)/i.test(ua) || /FxiOS\/(\d+)/i.test(ua)) {
122
- const match = ua.match(/(?:Firefox|FxiOS)\/(\d+)/i);
123
- browser = `Firefox ${match ? match[1] : ''}`.trim();
124
- } else if (/Safari/i.test(ua) && /Version\/(\d+)/i.test(ua)) {
125
- const match = ua.match(/Version\/(\d+)/i);
126
- browser = `Safari ${match ? match[1] : ''}`.trim();
101
+ if ((match = ua.match(/Edg(e|A|iOS)?\/(\d+)/i))) {
102
+ browser = `Edge ${match[2] || ''}`.trim();
103
+ } else if ((match = ua.match(/OPR\/(\d+)/i))) {
104
+ browser = `Opera ${match[1] || ''}`.trim();
105
+ } else if ((match = ua.match(/SamsungBrowser\/(\d+)/i))) {
106
+ browser = `Samsung Internet ${match[1] || ''}`.trim();
107
+ } else if ((match = ua.match(/Brave\/(\d+)/i))) {
108
+ browser = `Brave ${match[1] || ''}`.trim();
109
+ } else if ((match = ua.match(/Vivaldi\/(\d+)/i))) {
110
+ browser = `Vivaldi ${match[1] || ''}`.trim();
111
+ } else if (!/Chromium/i.test(ua) && (match = ua.match(/Chrome\/(\d+)/i))) {
112
+ browser = `Chrome ${match[1] || ''}`.trim();
113
+ } else if ((match = ua.match(/(?:Firefox|FxiOS)\/(\d+)/i))) {
114
+ browser = `Firefox ${match[1] || ''}`.trim();
115
+ } else if ((match = ua.match(/Version\/(\d+)/i)) && /Safari/i.test(ua)) {
116
+ browser = `Safari ${match[1] || ''}`.trim();
127
117
  } else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
128
118
  browser = 'Safari';
129
119
  } else if (/MSIE (\d+)/i.test(ua) || (/Trident/i.test(ua) && /rv:(\d+)/i.test(ua))) {
@@ -144,7 +134,7 @@ function parseUserAgent(userAgent = '') {
144
134
  else if (isMobile) device = 'mobile';
145
135
  else if (isDesktop) device = 'desktop';
146
136
 
147
- return {
137
+ const result = {
148
138
  browser,
149
139
  os,
150
140
  device,
@@ -153,9 +143,18 @@ function parseUserAgent(userAgent = '') {
153
143
  isDesktop,
154
144
  isBot,
155
145
  };
146
+
147
+ if (uaCache.size >= MAX_UA_CACHE) {
148
+ const oldestKey = uaCache.keys().next().value;
149
+ uaCache.delete(oldestKey);
150
+ }
151
+ uaCache.set(ua, result);
152
+
153
+ return { ...result };
156
154
  }
157
155
 
158
156
  module.exports = {
159
157
  getClientIp,
160
158
  parseUserAgent,
161
159
  };
160
+
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const zlib = require('zlib');
8
- const { supportsBrotli } = require('./negotiate');
8
+ const negotiate = require('./negotiate');
9
9
 
10
10
  /**
11
11
  * Create a zlib compression transform stream based on encoding
@@ -23,7 +23,7 @@ function createCompressionStream(encoding, options = {}) {
23
23
 
24
24
  switch (enc) {
25
25
  case 'br': {
26
- if (!supportsBrotli()) {
26
+ if (options.supportsBrotli === false || !negotiate.supportsBrotli()) {
27
27
  return null;
28
28
  }
29
29
  const brotliOpts = { ...(options.brotli || {}) };
@@ -55,6 +55,7 @@ function createCompressionMiddleware(options = {}) {
55
55
  const customFilter = typeof options.filter === 'function' ? options.filter : null;
56
56
 
57
57
  const comp = compression({
58
+ ...options,
58
59
  threshold,
59
60
  brotli: options.brotli,
60
61
  level: options.level,
@@ -73,7 +74,6 @@ function createCompressionMiddleware(options = {}) {
73
74
  }
74
75
  return compression.filter(req, res);
75
76
  },
76
- ...options,
77
77
  });
78
78
 
79
79
  return function compressionMiddleware(req, res, next) {
@@ -69,6 +69,9 @@ function parseAcceptEncoding(header) {
69
69
  return results;
70
70
  }
71
71
 
72
+ const DEFAULT_SUPPORTED_ENCODINGS = Object.freeze(getDefaultSupportedEncodings());
73
+ const DEFAULT_SUPPORTED_STRING = DEFAULT_SUPPORTED_ENCODINGS.join(',');
74
+
72
75
  const selectEncodingCache = new Map();
73
76
  const MAX_ENCODING_CACHE_SIZE = 128;
74
77
 
@@ -83,16 +86,20 @@ function selectEncoding(acceptEncoding, supportedEncodings) {
83
86
  return 'identity';
84
87
  }
85
88
 
86
- const supported = Array.isArray(supportedEncodings) && supportedEncodings.length > 0
87
- ? supportedEncodings.map(e => e.toLowerCase()).filter(e => e !== 'br' || supportsBrotli())
88
- : getDefaultSupportedEncodings();
89
+ const isDefault = !supportedEncodings || !Array.isArray(supportedEncodings) || supportedEncodings.length === 0;
90
+ const cacheKey = isDefault
91
+ ? `${acceptEncoding}|${DEFAULT_SUPPORTED_STRING}`
92
+ : `${acceptEncoding}|${supportedEncodings.join(',')}`;
89
93
 
90
- const cacheKey = `${acceptEncoding}|${supported.join(',')}`;
91
94
  const cached = selectEncodingCache.get(cacheKey);
92
95
  if (cached !== undefined) {
93
96
  return cached;
94
97
  }
95
98
 
99
+ const supported = isDefault
100
+ ? DEFAULT_SUPPORTED_ENCODINGS
101
+ : supportedEncodings.map(e => e.toLowerCase()).filter(e => e !== 'br' || supportsBrotli());
102
+
96
103
  const clientPreferences = parseAcceptEncoding(acceptEncoding);
97
104
  if (clientPreferences.length === 0) {
98
105
  if (selectEncodingCache.size < MAX_ENCODING_CACHE_SIZE) {
@@ -3,19 +3,7 @@
3
3
  * @module core/content/renderer
4
4
  */
5
5
 
6
- /**
7
- * Escape HTML for text nodes (not for rich-text which uses | safe separately).
8
- * @param {unknown} value
9
- * @returns {string}
10
- */
11
- function escapeHtml(value) {
12
- if (value === null || value === undefined) return '';
13
- return String(value)
14
- .replace(/&/g, '&amp;')
15
- .replace(/</g, '&lt;')
16
- .replace(/>/g, '&gt;')
17
- .replace(/"/g, '&quot;');
18
- }
6
+ const { escapeHtml } = require('../utils/html');
19
7
 
20
8
  /**
21
9
  * @param {unknown} value