webspresso 0.0.103 → 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 (60) hide show
  1. package/bin/commands/dev.js +3 -2
  2. package/bin/utils/orm-map-html.js +2 -8
  3. package/core/auth/jwt.js +2 -4
  4. package/core/auth/oauth.js +38 -14
  5. package/core/auth/totp.js +3 -15
  6. package/core/auth/user-agent.js +1 -21
  7. package/core/content/renderer.js +1 -13
  8. package/core/errors/middleware.js +2 -8
  9. package/core/forms/form-builder.js +1 -9
  10. package/core/orm/index.js +5 -0
  11. package/core/orm/query-filters.js +393 -0
  12. package/core/shutdown/shutdown-manager.js +16 -16
  13. package/core/utils/crypto.js +73 -0
  14. package/core/utils/escape-html.js +7 -0
  15. package/core/utils/fetch.js +120 -0
  16. package/core/utils/get-client-ip.js +6 -0
  17. package/core/utils/html.js +72 -0
  18. package/core/utils/ip.js +86 -0
  19. package/core/utils/safe-compare.js +3 -0
  20. package/index.d.ts +11 -1
  21. package/index.js +10 -0
  22. package/package.json +1 -1
  23. package/plugins/admin-panel/auth.js +15 -7
  24. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  25. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  26. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  27. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  28. package/plugins/admin-panel/core/api-extensions.js +1 -75
  29. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  30. package/plugins/api-key/crypto.js +1 -24
  31. package/plugins/audit-log/api-handlers.js +11 -1
  32. package/plugins/audit-log/middleware.js +11 -2
  33. package/plugins/basic-auth/index.js +2 -26
  34. package/plugins/cors.js +2 -2
  35. package/plugins/csrf/index.js +3 -15
  36. package/plugins/file-manager/admin-component.js +3 -1
  37. package/plugins/file-manager/core.js +34 -4
  38. package/plugins/mcp/auth.js +1 -13
  39. package/plugins/mcp/index.js +23 -1
  40. package/plugins/mcp/transports/sse.js +10 -1
  41. package/plugins/orm-cache-admin/admin-component.js +3 -2
  42. package/plugins/polar/src/api.js +9 -2
  43. package/plugins/polar/src/webhooks.js +3 -8
  44. package/plugins/recaptcha/helpers.js +1 -7
  45. package/plugins/recaptcha/verify.js +37 -23
  46. package/plugins/redirect/index.js +51 -3
  47. package/plugins/rest-resources/index.js +1 -207
  48. package/plugins/schema-explorer/admin-component.js +4 -2
  49. package/plugins/schema-explorer.js +40 -29
  50. package/plugins/seo-checker/index.js +5 -2
  51. package/plugins/site-analytics/tracking.js +19 -16
  52. package/plugins/sitemap.js +18 -15
  53. package/plugins/swagger.js +9 -14
  54. package/plugins/webhooks/crypto.js +1 -18
  55. package/plugins/webhooks/dispatcher.js +6 -1
  56. package/plugins/webhooks/matcher.js +25 -10
  57. package/plugins/webhooks/sender.js +32 -2
  58. package/src/dev-reload/index.js +47 -6
  59. package/src/helpers.js +21 -9
  60. package/src/server.js +2 -2
@@ -4,32 +4,7 @@
4
4
  * @module plugins/basic-auth
5
5
  */
6
6
 
7
- const crypto = require('crypto');
8
-
9
- /**
10
- * Compare two strings in constant time to prevent timing attacks.
11
- * Uses dummy buffer comparison if lengths differ to avoid leaking length via response timing.
12
- *
13
- * @param {string} a
14
- * @param {string} b
15
- * @returns {boolean}
16
- */
17
- function safeCompare(a, b) {
18
- if (typeof a !== 'string' || typeof b !== 'string') {
19
- return false;
20
- }
21
-
22
- const bufA = Buffer.from(a, 'utf8');
23
- const bufB = Buffer.from(b, 'utf8');
24
-
25
- if (bufA.length !== bufB.length) {
26
- // Perform dummy timing-safe comparison with itself to preserve constant execution time
27
- crypto.timingSafeEqual(bufA, bufA);
28
- return false;
29
- }
30
-
31
- return crypto.timingSafeEqual(bufA, bufB);
32
- }
7
+ const { safeCompare } = require('../../core/utils/crypto');
33
8
 
34
9
  /**
35
10
  * Parse an HTTP Authorization header for Basic credentials.
@@ -69,6 +44,7 @@ function parseBasicAuthHeader(header) {
69
44
  * @returns {{ username: string, password: string } | null}
70
45
  */
71
46
  function extractCredentials(req) {
47
+ if (!req || !req.headers) return null;
72
48
  const header = req.headers.authorization || req.headers.Authorization;
73
49
  return parseBasicAuthHeader(header);
74
50
  }
package/plugins/cors.js CHANGED
@@ -105,7 +105,7 @@ function createCorsMiddleware(options = {}) {
105
105
  return next();
106
106
  }
107
107
 
108
- const reqOrigin = req.headers.origin;
108
+ const reqOrigin = req.headers?.origin;
109
109
 
110
110
  isOriginAllowed(reqOrigin, (err, allowedOrigin) => {
111
111
  if (err) {
@@ -134,7 +134,7 @@ function createCorsMiddleware(options = {}) {
134
134
  }
135
135
  if (allowedHeadersStr) {
136
136
  res.setHeader('Access-Control-Allow-Headers', allowedHeadersStr);
137
- } else if (req.headers['access-control-request-headers']) {
137
+ } else if (req.headers?.['access-control-request-headers']) {
138
138
  res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
139
139
  }
140
140
  if (config.maxAge) {
@@ -15,21 +15,7 @@ const NOOP = () => {};
15
15
  const EMPTY_OBJECT = Object.freeze({});
16
16
  const ignoreRegexCache = new Map();
17
17
 
18
- /**
19
- * Compare two strings in constant time to prevent timing attacks
20
- * @param {string} a
21
- * @param {string} b
22
- * @returns {boolean}
23
- */
24
- function safeCompare(a, b) {
25
- if (typeof a !== 'string' || typeof b !== 'string') return false;
26
- if (a.length !== b.length) return false;
27
- try {
28
- return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
29
- } catch (err) {
30
- return false;
31
- }
32
- }
18
+ const { safeCompare } = require('../../core/utils/crypto');
33
19
 
34
20
  /**
35
21
  * Check if the request path matches any ignore patterns
@@ -241,3 +227,5 @@ function csrfPlugin(options = {}) {
241
227
 
242
228
  module.exports = csrfPlugin;
243
229
  module.exports.csrfPlugin = csrfPlugin;
230
+ module.exports.safeCompare = safeCompare;
231
+ csrfPlugin.safeCompare = safeCompare;
@@ -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 = '';
@@ -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
  };
@@ -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;