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
@@ -173,10 +173,12 @@ function generateFileManagerComponent(options = {}) {
173
173
 
174
174
  var uploadUrl = (window.__ADMIN_PATH__ || '/_admin') + '/api' + API + '/upload?path=' + encodeURIComponent(currentPath);
175
175
 
176
- fetch(uploadUrl, {
176
+ var fetchFn = (typeof window !== 'undefined' && window.Admin && window.Admin.fetch) ? window.Admin.fetch : fetch;
177
+ fetchFn(uploadUrl, {
177
178
  method: 'POST',
178
179
  body: fd,
179
180
  credentials: 'include',
181
+ timeout: 60000,
180
182
  }).then(function(r) { return r.json(); }).then(function(res) {
181
183
  uploading = false;
182
184
  uploadStatus = '';
@@ -4,15 +4,7 @@
4
4
  */
5
5
 
6
6
  const multer = require('multer');
7
- const {
8
- listFiles,
9
- createDirectory,
10
- renameItem,
11
- deleteItem,
12
- moveItem,
13
- saveUploadedFile,
14
- calculateStorageUsage,
15
- } = require('./core');
7
+ const core = require('./core');
16
8
 
17
9
  /**
18
10
  * Create Multer middleware for in-memory upload parsing
@@ -71,7 +63,7 @@ function createFileManagerApiHandlers(options = {}) {
71
63
  const permissions = options.permissions || null;
72
64
  const securityOpts = options.security || {};
73
65
 
74
- const parseUpload = createUploadMiddleware(maxUploadBytes);
66
+ const parseUpload = options.uploadMiddleware || createUploadMiddleware(maxUploadBytes);
75
67
 
76
68
  return {
77
69
  async list(req, res) {
@@ -88,7 +80,7 @@ function createFileManagerApiHandlers(options = {}) {
88
80
  const page = req.query.page ? parseInt(req.query.page, 10) : undefined;
89
81
  const perPage = req.query.perPage ? parseInt(req.query.perPage, 10) : undefined;
90
82
 
91
- const result = await listFiles(baseDir, relPath, {
83
+ const result = await core.listFiles(baseDir, relPath, {
92
84
  search,
93
85
  type,
94
86
  sort,
@@ -115,7 +107,7 @@ function createFileManagerApiHandlers(options = {}) {
115
107
  if (!name) {
116
108
  return res.status(400).json({ success: false, error: 'Directory name is required' });
117
109
  }
118
- const result = await createDirectory(baseDir, relPath, name);
110
+ const result = await core.createDirectory(baseDir, relPath, name);
119
111
  return res.status(201).json({ success: true, data: result });
120
112
  } catch (err) {
121
113
  const status = err.statusCode || err.status || 400;
@@ -133,7 +125,7 @@ function createFileManagerApiHandlers(options = {}) {
133
125
  if (!itemPath || !newName) {
134
126
  return res.status(400).json({ success: false, error: 'Path and newName are required' });
135
127
  }
136
- const result = await renameItem(baseDir, itemPath, newName);
128
+ const result = await core.renameItem(baseDir, itemPath, newName);
137
129
  return res.json({ success: true, data: result });
138
130
  } catch (err) {
139
131
  const status = err.statusCode || err.status || 400;
@@ -151,7 +143,7 @@ function createFileManagerApiHandlers(options = {}) {
151
143
  if (!source) {
152
144
  return res.status(400).json({ success: false, error: 'Source path is required' });
153
145
  }
154
- const result = await moveItem(baseDir, source, destination);
146
+ const result = await core.moveItem(baseDir, source, destination);
155
147
  return res.json({ success: true, data: result });
156
148
  } catch (err) {
157
149
  const status = err.statusCode || err.status || 400;
@@ -169,7 +161,7 @@ function createFileManagerApiHandlers(options = {}) {
169
161
  if (!itemPath) {
170
162
  return res.status(400).json({ success: false, error: 'Path is required' });
171
163
  }
172
- const result = await deleteItem(baseDir, itemPath);
164
+ const result = await core.deleteItem(baseDir, itemPath);
173
165
  return res.json({ success: true, data: result });
174
166
  } catch (err) {
175
167
  const status = err.statusCode || err.status || 400;
@@ -199,7 +191,7 @@ function createFileManagerApiHandlers(options = {}) {
199
191
 
200
192
  // Storage Quota Check
201
193
  if (maxTotalStorageBytes && typeof maxTotalStorageBytes === 'number') {
202
- const currentUsage = await calculateStorageUsage(baseDir);
194
+ const currentUsage = await core.calculateStorageUsage(baseDir);
203
195
  const incomingBytes = files.reduce((acc, f) => acc + (f.size || f.buffer?.length || 0), 0);
204
196
  if (currentUsage + incomingBytes > maxTotalStorageBytes) {
205
197
  return res.status(507).json({
@@ -229,7 +221,7 @@ function createFileManagerApiHandlers(options = {}) {
229
221
  try {
230
222
  const uploaded = [];
231
223
  for (const file of files) {
232
- const saved = await saveUploadedFile(
224
+ const saved = await core.saveUploadedFile(
233
225
  baseDir,
234
226
  targetRelPath,
235
227
  file.buffer,
@@ -257,5 +249,6 @@ function createFileManagerApiHandlers(options = {}) {
257
249
 
258
250
  module.exports = {
259
251
  createFileManagerApiHandlers,
252
+ createUploadMiddleware,
260
253
  isActionAllowed,
261
254
  };
@@ -137,7 +137,12 @@ function resolveSafePath(baseDir, relPath = '') {
137
137
  }
138
138
 
139
139
  // Prevent URL decoded traversal attempts
140
- const decodedPath = decodeURIComponent(relPath);
140
+ let decodedPath;
141
+ try {
142
+ decodedPath = decodeURIComponent(relPath);
143
+ } catch (err) {
144
+ throw new ValidationError('Malformed URI path sequence', 400);
145
+ }
141
146
  if (decodedPath.includes('\0')) {
142
147
  throw new SecurityError('Null byte detected in path', 400);
143
148
  }
@@ -237,7 +242,7 @@ function buildBreadcrumbs(relPath) {
237
242
  */
238
243
  async function listFiles(baseDir, relPath = '', options = {}) {
239
244
  const targetDir = resolveSafePath(baseDir, relPath);
240
- const normalizedRel = path.relative(path.resolve(baseDir), targetDir).replace(/\\/g, '');
245
+ const normalizedRel = path.relative(path.resolve(baseDir), targetDir).replace(/\\/g, '/');
241
246
  const publicBase = normalizePublicBase(options.publicBasePath);
242
247
 
243
248
  let dirents;
@@ -456,6 +461,7 @@ async function deleteItem(baseDir, itemRelPath) {
456
461
  await fs.unlink(targetFullPath);
457
462
  }
458
463
 
464
+ invalidateStorageCache(baseDir);
459
465
  return { success: true, path: itemRelPath };
460
466
  }
461
467
 
@@ -487,12 +493,33 @@ async function moveItem(baseDir, sourceRelPath, destDirRelPath) {
487
493
  return { success: true, oldPath: sourceRelPath, newPath: newRelPath };
488
494
  }
489
495
 
496
+ const storageCache = new Map();
497
+ const STORAGE_CACHE_TTL = 15000; // 15 seconds
498
+
499
+ function invalidateStorageCache(baseDir) {
500
+ if (baseDir) {
501
+ storageCache.delete(path.resolve(baseDir));
502
+ } else {
503
+ storageCache.clear();
504
+ }
505
+ }
506
+
490
507
  /**
491
508
  * Calculate total disk usage in bytes under baseDir
492
509
  * @param {string} baseDir
510
+ * @param {Object} [options]
511
+ * @param {boolean} [options.forceRefresh=false]
493
512
  * @returns {Promise<number>}
494
513
  */
495
- async function calculateStorageUsage(baseDir) {
514
+ async function calculateStorageUsage(baseDir, options = {}) {
515
+ const resolvedBase = path.resolve(baseDir);
516
+ const now = Date.now();
517
+ const cached = storageCache.get(resolvedBase);
518
+
519
+ if (!options.forceRefresh && cached && (now - cached.timestamp) < STORAGE_CACHE_TTL) {
520
+ return cached.bytes;
521
+ }
522
+
496
523
  let totalBytes = 0;
497
524
  async function scan(dir) {
498
525
  try {
@@ -509,7 +536,8 @@ async function calculateStorageUsage(baseDir) {
509
536
  }
510
537
  } catch (e) {}
511
538
  }
512
- await scan(path.resolve(baseDir));
539
+ await scan(resolvedBase);
540
+ storageCache.set(resolvedBase, { bytes: totalBytes, timestamp: now });
513
541
  return totalBytes;
514
542
  }
515
543
 
@@ -568,6 +596,7 @@ async function saveUploadedFile(baseDir, targetDirRelPath, buffer, originalName,
568
596
  resolveSafePath(baseDir, path.relative(baseDir, targetPath));
569
597
 
570
598
  await fs.writeFile(targetPath, finalBuffer);
599
+ invalidateStorageCache(baseDir);
571
600
 
572
601
  const relFilePath = path.relative(path.resolve(baseDir), targetPath).replace(/\\/g, '/');
573
602
  const publicBase = normalizePublicBase(publicBasePath);
@@ -598,4 +627,5 @@ module.exports = {
598
627
  moveItem,
599
628
  saveUploadedFile,
600
629
  calculateStorageUsage,
630
+ invalidateStorageCache,
601
631
  };
@@ -114,15 +114,17 @@ function fileManagerPlugin(options = {}) {
114
114
  // so window.__openFileManagerPicker is available across all admin pages (models, records, rich-text)
115
115
  if (typeof adminApi.getRegistry === 'function') {
116
116
  const registry = adminApi.getRegistry();
117
- registry.configure({
118
- fileManager: {
119
- enabled: true,
120
- apiPrefix,
121
- publicBasePath,
122
- pageTitle,
123
- },
124
- });
125
- registry.registerClientComponent('files', componentCode);
117
+ if (registry) {
118
+ registry.configure({
119
+ fileManager: {
120
+ enabled: true,
121
+ apiPrefix,
122
+ publicBasePath,
123
+ pageTitle,
124
+ },
125
+ });
126
+ registry.registerClientComponent('files', componentCode);
127
+ }
126
128
  }
127
129
 
128
130
  adminApi.registerModule({
@@ -24,6 +24,16 @@ const DANGEROUS_EXTENSIONS = new Set([
24
24
  'htaccess', 'htpasswd', 'ini', 'env', 'config', 'conf', 'passwd', 'shadow',
25
25
  ]);
26
26
 
27
+ /**
28
+ * Common safe extensions allowed when passed as raw extensions (without filename dot)
29
+ */
30
+ const COMMON_SAFE_EXTENSIONS = new Set([
31
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff',
32
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf',
33
+ 'txt', 'csv', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'jsx', 'tsx',
34
+ 'sql', 'yaml', 'yml', 'zip', 'tar', 'gz', 'mp3', 'wav', 'mp4', 'mov', 'webm',
35
+ ]);
36
+
27
37
  /**
28
38
  * Known magic numbers table for high-speed signature verification.
29
39
  * Only validates when extension matches known binary format.
@@ -118,9 +128,8 @@ function validateSafeExtension(filename) {
118
128
  throw new ValidationError('Filename must have a valid extension');
119
129
  }
120
130
 
121
- // Check all dotted segments against dangerous extensions to block double extensions (e.g. payload.php.jpg)
122
- for (let i = 1; i < parts.length; i++) {
123
- const segment = parts[i];
131
+ // Check all segments against dangerous extensions to block double extensions and dangerous dotfiles (e.g. .env, payload.php.jpg)
132
+ for (const segment of parts) {
124
133
  if (DANGEROUS_EXTENSIONS.has(segment)) {
125
134
  throw new SecurityError(
126
135
  `Dangerous extension or double-extension detected: .${segment}`,
@@ -129,11 +138,14 @@ function validateSafeExtension(filename) {
129
138
  }
130
139
  }
131
140
 
132
- const finalExt = parts[parts.length - 1];
133
- if (DANGEROUS_EXTENSIONS.has(finalExt)) {
134
- throw new SecurityError(`File type .${finalExt} is forbidden for upload`, 403);
141
+ if (parts.length < 2 && !cleanName.startsWith('.')) {
142
+ if (COMMON_SAFE_EXTENSIONS.has(parts[0])) {
143
+ return parts[0];
144
+ }
145
+ throw new ValidationError('Filename must have a valid extension');
135
146
  }
136
147
 
148
+ const finalExt = parts[parts.length - 1];
137
149
  return finalExt;
138
150
  }
139
151
 
@@ -6,19 +6,7 @@
6
6
 
7
7
  const crypto = require('crypto');
8
8
 
9
- /**
10
- * Constant-time string comparison to avoid timing attacks
11
- * @param {string} a
12
- * @param {string} b
13
- * @returns {boolean}
14
- */
15
- function safeEqual(a, b) {
16
- if (typeof a !== 'string' || typeof b !== 'string') return false;
17
- const bufA = Buffer.from(a);
18
- const bufB = Buffer.from(b);
19
- if (bufA.length !== bufB.length) return false;
20
- return crypto.timingSafeEqual(bufA, bufB);
21
- }
9
+ const { safeCompare: safeEqual } = require('../../core/utils/crypto');
22
10
 
23
11
  /**
24
12
  * Check if incoming request is from localhost
@@ -52,6 +52,7 @@ function mcpPlugin(options = {}) {
52
52
  } = options;
53
53
 
54
54
  let serverInstance = null;
55
+ let sseTransport = null;
55
56
 
56
57
  return {
57
58
  name: 'mcp',
@@ -63,6 +64,19 @@ function mcpPlugin(options = {}) {
63
64
  registerTool: (tool) => serverInstance && serverInstance.registerTool(tool),
64
65
  registerResource: (res) => serverInstance && serverInstance.registerResource(res),
65
66
  registerPrompt: (prompt) => serverInstance && serverInstance.registerPrompt(prompt),
67
+ closeTransport: () => sseTransport && sseTransport.close && sseTransport.close(),
68
+ },
69
+
70
+ dispose() {
71
+ if (sseTransport && typeof sseTransport.close === 'function') {
72
+ sseTransport.close();
73
+ }
74
+ },
75
+
76
+ shutdown() {
77
+ if (sseTransport && typeof sseTransport.close === 'function') {
78
+ sseTransport.close();
79
+ }
66
80
  },
67
81
 
68
82
  register(ctx) {
@@ -180,12 +194,20 @@ function mcpPlugin(options = {}) {
180
194
  const authMiddleware =
181
195
  auth.token || auth.localhostOnly || auth.verify ? createMcpAuthMiddleware(auth) : null;
182
196
 
183
- mountSseTransport({
197
+ sseTransport = mountSseTransport({
184
198
  app,
185
199
  server: serverInstance,
186
200
  path,
187
201
  authMiddleware,
188
202
  });
203
+
204
+ if (typeof ctx.onDispose === 'function') {
205
+ ctx.onDispose(() => {
206
+ if (sseTransport && typeof sseTransport.close === 'function') {
207
+ sseTransport.close();
208
+ }
209
+ });
210
+ }
189
211
  },
190
212
  };
191
213
  }
@@ -150,7 +150,16 @@ function mountSseTransport(options) {
150
150
  }
151
151
  });
152
152
 
153
- return { sessions };
153
+ const close = () => {
154
+ for (const [id, session] of sessions.entries()) {
155
+ try {
156
+ session.res.end();
157
+ } catch {}
158
+ }
159
+ sessions.clear();
160
+ };
161
+
162
+ return { sessions, close };
154
163
  }
155
164
 
156
165
  module.exports = {
@@ -8,12 +8,13 @@ function generateOrmCacheAdminComponent() {
8
8
  (function() {
9
9
 
10
10
  function ormCacheApi(method, path, body) {
11
- var opts = { method: method, credentials: 'same-origin' };
11
+ var opts = { method: method, credentials: 'same-origin', timeout: 30000 };
12
12
  if (body !== undefined) {
13
13
  opts.headers = { 'Content-Type': 'application/json' };
14
14
  opts.body = JSON.stringify(body);
15
15
  }
16
- return fetch('/_admin/api/orm-cache' + path, opts).then(function(r) {
16
+ var fetchFn = (typeof window !== 'undefined' && window.Admin && window.Admin.fetch) ? window.Admin.fetch : fetch;
17
+ return fetchFn('/_admin/api/orm-cache' + path, opts).then(function(r) {
17
18
  return r.json().then(function(j) {
18
19
  if (!r.ok) throw new Error(j.error || r.statusText);
19
20
  return j;
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { fetchWithTimeout } = require('../../../core/utils/fetch');
4
+
3
5
  function getPolarApiBase(sandbox) {
4
6
  return sandbox ? 'https://sandbox-api.polar.sh/v1' : 'https://api.polar.sh/v1';
5
7
  }
@@ -11,14 +13,17 @@ function getPolarApiBase(sandbox) {
11
13
  * @param {Object} [opts.body]
12
14
  * @param {string} opts.accessToken
13
15
  * @param {boolean} [opts.sandbox=false]
16
+ * @param {number} [opts.timeoutMs=15000]
17
+ * @param {AbortSignal} [opts.signal]
14
18
  */
15
- async function polarApiRequest(path, { method = 'GET', body, accessToken, sandbox = false } = {}) {
19
+ async function polarApiRequest(path, { method = 'GET', body, accessToken, sandbox = false, timeoutMs = 15000, signal } = {}) {
16
20
  if (!accessToken) {
17
21
  throw new Error('POLAR_ACCESS_TOKEN is not configured');
18
22
  }
19
23
 
20
24
  const normalizedPath = path.startsWith('/') ? path : `/${path}`;
21
- const res = await fetch(`${getPolarApiBase(sandbox)}${normalizedPath}`, {
25
+
26
+ const res = await fetchWithTimeout(`${getPolarApiBase(sandbox)}${normalizedPath}`, {
22
27
  method,
23
28
  headers: {
24
29
  Authorization: `Bearer ${accessToken}`,
@@ -26,6 +31,8 @@ async function polarApiRequest(path, { method = 'GET', body, accessToken, sandbo
26
31
  Accept: 'application/json',
27
32
  },
28
33
  body: body ? JSON.stringify(body) : undefined,
34
+ timeout: timeoutMs,
35
+ signal,
29
36
  });
30
37
 
31
38
  const raw = await res.text();
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
+ const { safeCompare } = require('../../../core/utils/crypto');
4
5
  const { syncPolarBillingForUser, applyPolarProSubscription } = require('./sync');
5
6
  const {
6
7
  collectPolarMetadata,
@@ -46,14 +47,8 @@ function verifyPolarWebhook(rawPayload, headers, secret) {
46
47
 
47
48
  for (const candidate of candidates) {
48
49
  for (const exp of expectedList) {
49
- try {
50
- const a = Buffer.from(candidate);
51
- const b = Buffer.from(exp);
52
- if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
53
- return true;
54
- }
55
- } catch {
56
- // continue
50
+ if (safeCompare(candidate, exp)) {
51
+ return true;
57
52
  }
58
53
  }
59
54
  }
@@ -72,13 +72,7 @@ function recaptchaV3ExecuteScript(opts = {}) {
72
72
  return hidden + script;
73
73
  }
74
74
 
75
- function escapeHtml(s) {
76
- return String(s)
77
- .replace(/&/g, '&amp;')
78
- .replace(/"/g, '&quot;')
79
- .replace(/</g, '&lt;')
80
- .replace(/>/g, '&gt;');
81
- }
75
+ const { escapeHtml } = require('../../core/utils/html');
82
76
 
83
77
  function escapeJsInline(s) {
84
78
  return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
@@ -3,6 +3,9 @@
3
3
  * @module plugins/recaptcha/verify
4
4
  */
5
5
 
6
+ const { fetchWithTimeout } = require('../../core/utils/fetch');
7
+ const { getClientIp } = require('../../core/utils/ip');
8
+
6
9
  const SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
7
10
 
8
11
  /**
@@ -20,7 +23,8 @@ const SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
20
23
  * @param {VerifyOptions} options
21
24
  * @returns {Promise<{ success: boolean, score?: number, action?: string, hostname?: string, challenge_ts?: string, errorCodes?: string[], raw: object }>}
22
25
  */
23
- async function verifyRecaptcha(options) {
26
+ async function verifyRecaptcha(options = {}) {
27
+ const opts = options || {};
24
28
  const {
25
29
  secret,
26
30
  token,
@@ -28,7 +32,8 @@ async function verifyRecaptcha(options) {
28
32
  version = 'v2',
29
33
  minScore = 0.5,
30
34
  expectedAction,
31
- } = options;
35
+ timeoutMs = 10000,
36
+ } = opts;
32
37
 
33
38
  if (!secret || typeof secret !== 'string') {
34
39
  throw new Error('recaptcha: secret is required');
@@ -49,13 +54,24 @@ async function verifyRecaptcha(options) {
49
54
  body.set('remoteip', remoteIp);
50
55
  }
51
56
 
52
- const res = await fetch(SITEVERIFY_URL, {
53
- method: 'POST',
54
- headers: {
55
- 'Content-Type': 'application/x-www-form-urlencoded',
56
- },
57
- body: body.toString(),
58
- });
57
+ let res;
58
+ try {
59
+ res = await fetchWithTimeout(SITEVERIFY_URL, {
60
+ method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/x-www-form-urlencoded',
63
+ },
64
+ body: body.toString(),
65
+ timeout: timeoutMs,
66
+ });
67
+ } catch (netErr) {
68
+ const isTimeout = netErr.name === 'AbortError' || netErr.name === 'TimeoutError' || netErr.code === 'ETIMEDOUT';
69
+ return {
70
+ success: false,
71
+ errorCodes: [isTimeout ? 'timeout' : 'network-error'],
72
+ raw: {},
73
+ };
74
+ }
59
75
 
60
76
  if (!res.ok) {
61
77
  return {
@@ -66,8 +82,17 @@ async function verifyRecaptcha(options) {
66
82
  }
67
83
 
68
84
  /** @type {Record<string, unknown>} */
69
- const raw = await res.json();
70
- const googleSuccess = raw.success === true;
85
+ let raw;
86
+ try {
87
+ raw = await res.json();
88
+ } catch (jsonErr) {
89
+ return {
90
+ success: false,
91
+ errorCodes: ['invalid-json-response'],
92
+ raw: {},
93
+ };
94
+ }
95
+ const googleSuccess = raw && raw.success === true;
71
96
 
72
97
  if (!googleSuccess) {
73
98
  return {
@@ -129,18 +154,7 @@ async function verifyRecaptcha(options) {
129
154
  * @returns {string|undefined}
130
155
  */
131
156
  function getRemoteIp(req) {
132
- if (!req) return undefined;
133
- const xf = req.headers && req.headers['x-forwarded-for'];
134
- if (typeof xf === 'string' && xf.length) {
135
- return xf.split(',')[0].trim();
136
- }
137
- if (req.ip) {
138
- return req.ip;
139
- }
140
- if (req.socket && req.socket.remoteAddress) {
141
- return req.socket.remoteAddress;
142
- }
143
- return undefined;
157
+ return getClientIp(req, undefined);
144
158
  }
145
159
 
146
160
  module.exports = {
@@ -151,6 +151,7 @@ function methodMatches(compiled, method) {
151
151
  * @returns {{ name: string, version: string, description: string, register: Function }}
152
152
  */
153
153
  function redirectPlugin(options = {}) {
154
+ const opts = options || {};
154
155
  const {
155
156
  rules = [],
156
157
  defaultStatus = 302,
@@ -158,7 +159,7 @@ function redirectPlugin(options = {}) {
158
159
  allowExternal = false,
159
160
  trailingSlash = false,
160
161
  defaultMethods = ['GET', 'HEAD'],
161
- } = options;
162
+ } = opts;
162
163
 
163
164
  const pluginOpts = {
164
165
  defaultStatus: REDIRECT_STATUSES.has(Number(defaultStatus)) ? Number(defaultStatus) : 302,
@@ -169,16 +170,63 @@ function redirectPlugin(options = {}) {
169
170
  };
170
171
 
171
172
  const compiled = [];
172
- for (const rule of rules) {
173
+ const stringRulesMap = new Map();
174
+ let hasRegexRules = false;
175
+
176
+ const ruleList = Array.isArray(rules) ? rules : [];
177
+ for (const rule of ruleList) {
173
178
  const c = compileRule(rule, pluginOpts);
174
- if (c) compiled.push(c);
179
+ if (!c) continue;
180
+ compiled.push(c);
181
+ if (c.kind === 'string') {
182
+ const keys = [c.fromNorm];
183
+ if (!c.trailing) {
184
+ const alt = c.fromRaw.replace(/\/$/, '') || '/';
185
+ if (!keys.includes(alt)) keys.push(alt);
186
+ }
187
+ for (const k of keys) {
188
+ let list = stringRulesMap.get(k);
189
+ if (!list) {
190
+ list = [];
191
+ stringRulesMap.set(k, list);
192
+ }
193
+ list.push(c);
194
+ }
195
+ } else {
196
+ hasRegexRules = true;
197
+ }
175
198
  }
176
199
 
177
200
  function redirectMiddleware(req, res, next) {
201
+ if (!req) return next();
178
202
  const rawPath = req.path || '/';
179
203
  const pathForMatch = normalizePathForTrailingSlash(pluginOpts.trailingSlash, rawPath);
180
204
  const method = req.method || 'GET';
181
205
 
206
+ // Fast O(1) path when all configured rules are string based
207
+ if (!hasRegexRules) {
208
+ const candidates = stringRulesMap.get(pathForMatch) || (!pluginOpts.trailingSlash ? stringRulesMap.get(rawPath.replace(/\/$/, '') || '/') : null);
209
+ if (candidates) {
210
+ for (const c of candidates) {
211
+ if (!methodMatches(c, method)) continue;
212
+ if (!pathMatches(c, pathForMatch, rawPath)) continue;
213
+
214
+ let location = c.to;
215
+ if (pluginOpts.preserveQuery && !location.includes('?')) {
216
+ const full = req.originalUrl || req.url || '';
217
+ const qi = full.indexOf('?');
218
+ if (qi !== -1) {
219
+ location += full.slice(qi);
220
+ }
221
+ }
222
+ res.redirect(c.status, location);
223
+ return;
224
+ }
225
+ }
226
+ return next();
227
+ }
228
+
229
+ // Linear path when regex rules are present to preserve definition order
182
230
  for (const c of compiled) {
183
231
  if (!methodMatches(c, method)) continue;
184
232
  if (!pathMatches(c, pathForMatch, rawPath)) continue;