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
@@ -8,8 +8,7 @@ const { getAllModels } = require('../../core/orm/model');
8
8
  const { omit } = require('../../core/orm/utils');
9
9
  const { trimUrlPathSlashes } = require('../../core/url-path-normalize');
10
10
  const { validateQueryComplexity } = require('../../core/orm/complexity');
11
-
12
- const RESERVED_QUERY_KEYS = new Set(['page', 'perPage', 'sort', 'order', 'include', 'trashed']);
11
+ const { applyColumnFilters, RESERVED_QUERY_KEYS } = require('../../core/orm/query-filters');
13
12
 
14
13
  /**
15
14
  * Pluralize a PascalCase model name to a URL segment (e.g. User -> users, Company -> companies)
@@ -234,212 +233,7 @@ function parseSortParams(model, rawSort, rawOrder) {
234
233
  return result;
235
234
  }
236
235
 
237
- function applyColumnFilters(query, countQuery, model, req) {
238
- let q = query;
239
- let c = countQuery;
240
-
241
- function applyCondition(col, op, rawVal) {
242
- if (!model.columns.has(col)) return;
243
- const meta = model.columns.get(col);
244
- const colType = meta?.type;
245
-
246
- let operator = String(op || 'eq').toLowerCase();
247
- let val = rawVal;
248
-
249
- // Normalize booleans
250
- if (colType === 'boolean' && typeof val === 'string') {
251
- if (val === 'true' || val === '1') val = true;
252
- else if (val === 'false' || val === '0') val = false;
253
- }
254
-
255
- switch (operator) {
256
- case 'eq':
257
- case '=':
258
- if (val !== undefined && val !== '') {
259
- q = q.where(col, '=', val);
260
- c = c.where(col, '=', val);
261
- }
262
- break;
263
-
264
- case 'ne':
265
- case '!=':
266
- case '<>':
267
- if (val !== undefined && val !== '') {
268
- q = q.where(col, '!=', val);
269
- c = c.where(col, '!=', val);
270
- }
271
- break;
272
-
273
- case 'gt':
274
- case '>':
275
- if (val !== undefined && val !== '') {
276
- q = q.where(col, '>', val);
277
- c = c.where(col, '>', val);
278
- }
279
- break;
280
-
281
- case 'gte':
282
- case '>=':
283
- if (val !== undefined && val !== '') {
284
- q = q.where(col, '>=', val);
285
- c = c.where(col, '>=', val);
286
- }
287
- break;
288
-
289
- case 'lt':
290
- case '<':
291
- if (val !== undefined && val !== '') {
292
- q = q.where(col, '<', val);
293
- c = c.where(col, '<', val);
294
- }
295
- break;
296
-
297
- case 'lte':
298
- case '<=':
299
- if (val !== undefined && val !== '') {
300
- q = q.where(col, '<=', val);
301
- c = c.where(col, '<=', val);
302
- }
303
- break;
304
-
305
- case 'in': {
306
- const arr = Array.isArray(val)
307
- ? val
308
- : (typeof val === 'string' ? val.split(',').map(s => s.trim()).filter(Boolean) : [val]);
309
- if (arr.length > 0) {
310
- q = q.whereIn(col, arr);
311
- c = c.whereIn(col, arr);
312
- }
313
- break;
314
- }
315
-
316
- case 'nin':
317
- case 'notin': {
318
- const arr = Array.isArray(val)
319
- ? val
320
- : (typeof val === 'string' ? val.split(',').map(s => s.trim()).filter(Boolean) : [val]);
321
- if (arr.length > 0) {
322
- q = q.whereNotIn(col, arr);
323
- c = c.whereNotIn(col, arr);
324
- }
325
- break;
326
- }
327
-
328
- case 'like':
329
- if (val !== undefined && val !== '') {
330
- q = q.where(col, 'like', String(val));
331
- c = c.where(col, 'like', String(val));
332
- }
333
- break;
334
-
335
- case 'ilike':
336
- if (val !== undefined && val !== '') {
337
- q = q.where(col, 'ilike', String(val));
338
- c = c.where(col, 'ilike', String(val));
339
- }
340
- break;
341
-
342
- case 'contains':
343
- if (val !== undefined && val !== '') {
344
- q = q.where(col, 'like', `%${String(val)}%`);
345
- c = c.where(col, 'like', `%${String(val)}%`);
346
- }
347
- break;
348
-
349
- case 'startswith':
350
- if (val !== undefined && val !== '') {
351
- q = q.where(col, 'like', `${String(val)}%`);
352
- c = c.where(col, 'like', `${String(val)}%`);
353
- }
354
- break;
355
-
356
- case 'endswith':
357
- if (val !== undefined && val !== '') {
358
- q = q.where(col, 'like', `%${String(val)}`);
359
- c = c.where(col, 'like', `%${String(val)}`);
360
- }
361
- break;
362
-
363
- case 'isnull':
364
- case 'is_null': {
365
- const isTrue = val === true || val === 'true' || val === '1' || val === 1 || val === '';
366
- if (isTrue) {
367
- q = q.whereNull(col);
368
- c = c.whereNull(col);
369
- }
370
- break;
371
- }
372
-
373
- case 'isnotnull':
374
- case 'is_not_null': {
375
- const isTrue = val === true || val === 'true' || val === '1' || val === 1 || val === '';
376
- if (isTrue) {
377
- q = q.whereNotNull(col);
378
- c = c.whereNotNull(col);
379
- }
380
- break;
381
- }
382
-
383
- case 'between': {
384
- let parts = [];
385
- if (Array.isArray(val)) {
386
- parts = val;
387
- } else if (typeof val === 'string') {
388
- parts = val.split(',').map(s => s.trim());
389
- }
390
- if (parts.length === 2 && parts[0] !== '' && parts[1] !== '') {
391
- q = q.where(col, '>=', parts[0]).where(col, '<=', parts[1]);
392
- c = c.where(col, '>=', parts[0]).where(col, '<=', parts[1]);
393
- }
394
- break;
395
- }
396
-
397
- default:
398
- if (val !== undefined && val !== '') {
399
- q = q.where(col, '=', val);
400
- c = c.where(col, '=', val);
401
- }
402
- break;
403
- }
404
- }
405
-
406
- // 1. Process dedicated filter object ?filter[col]=... or ?filter[col][op]=...
407
- if (req.query.filter && typeof req.query.filter === 'object') {
408
- for (const [col, colFilter] of Object.entries(req.query.filter)) {
409
- if (!model.columns.has(col)) continue;
410
- if (colFilter && typeof colFilter === 'object' && !Array.isArray(colFilter)) {
411
- if (colFilter.op !== undefined && colFilter.value !== undefined) {
412
- applyCondition(col, colFilter.op, colFilter.value);
413
- } else {
414
- for (const [op, val] of Object.entries(colFilter)) {
415
- applyCondition(col, op, val);
416
- }
417
- }
418
- } else if (colFilter !== undefined && colFilter !== '') {
419
- applyCondition(col, 'eq', colFilter);
420
- }
421
- }
422
- }
423
236
 
424
- // 2. Process top-level query parameters ?price[gte]=100 or ?status=active
425
- for (const [key, value] of Object.entries(req.query)) {
426
- if (RESERVED_QUERY_KEYS.has(key) || key === 'filter') {
427
- continue;
428
- }
429
- if (!model.columns.has(key)) {
430
- continue;
431
- }
432
- if (value && typeof value === 'object' && !Array.isArray(value)) {
433
- for (const [op, val] of Object.entries(value)) {
434
- applyCondition(key, op, val);
435
- }
436
- } else if (value !== undefined && value !== '') {
437
- applyCondition(key, 'eq', value);
438
- }
439
- }
440
-
441
- return { query: q, countQuery: c };
442
- }
443
237
 
444
238
  /**
445
239
  * @param {Object} options
@@ -20,10 +20,12 @@ function generateSchemaExplorerAdminComponent(options = {}) {
20
20
  function getSchemaApi(method, path) {
21
21
  var adminPath = window.__ADMIN_PATH__ || '/_admin';
22
22
  var url = adminPath + '/api${apiPrefix}' + (path || '');
23
- return fetch(url, {
23
+ var fetchFn = (typeof window !== 'undefined' && window.Admin && window.Admin.fetch) ? window.Admin.fetch : fetch;
24
+ return fetchFn(url, {
24
25
  method: method || 'GET',
25
26
  credentials: 'include',
26
- headers: { 'Accept': 'application/json' }
27
+ headers: { 'Accept': 'application/json' },
28
+ timeout: 30000,
27
29
  }).then(function(res) {
28
30
  if (!res.ok) {
29
31
  return res.json().catch(function() { return { error: res.statusText }; }).then(function(err) {
@@ -59,6 +59,25 @@ function schemaExplorerPlugin(options = {}) {
59
59
 
60
60
  const pageTitle = title || adminTitle || menuLabel;
61
61
 
62
+ let cachedGraph = null;
63
+ let cachedGraphTime = 0;
64
+ const GRAPH_CACHE_TTL = 5000; // 5 seconds in dev, infinite in prod
65
+
66
+ function getEnrichedGraph() {
67
+ const isProd = process.env.NODE_ENV === 'production';
68
+ const now = Date.now();
69
+ if (cachedGraph && (isProd || (now - cachedGraphTime) < GRAPH_CACHE_TTL)) {
70
+ return cachedGraph;
71
+ }
72
+ const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
73
+ const relationships = extractAllRelationships(rawModels);
74
+ const enriched = enrichModelsWithGraph(rawModels, relationships);
75
+ const mermaid = generateMermaidErDiagram(rawModels, relationships);
76
+ cachedGraph = { rawModels, relationships, enriched, mermaid };
77
+ cachedGraphTime = now;
78
+ return cachedGraph;
79
+ }
80
+
62
81
  return {
63
82
  name: 'schema-explorer',
64
83
  version: '1.0.0',
@@ -66,6 +85,14 @@ function schemaExplorerPlugin(options = {}) {
66
85
 
67
86
  // Expose API for other plugins & programmatic consumption
68
87
  api: {
88
+ /**
89
+ * Invalidate schema explorer cache
90
+ */
91
+ invalidateCache() {
92
+ cachedGraph = null;
93
+ cachedGraphTime = 0;
94
+ },
95
+
69
96
  /**
70
97
  * Get all models metadata
71
98
  * @returns {Object[]}
@@ -79,9 +106,7 @@ function schemaExplorerPlugin(options = {}) {
79
106
  * @returns {Object[]}
80
107
  */
81
108
  getEnrichedModels() {
82
- const raw = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
83
- const rels = extractAllRelationships(raw);
84
- return enrichModelsWithGraph(raw, rels);
109
+ return getEnrichedGraph().enriched;
85
110
  },
86
111
 
87
112
  /**
@@ -109,8 +134,7 @@ function schemaExplorerPlugin(options = {}) {
109
134
  * @returns {Object[]}
110
135
  */
111
136
  getRelationships() {
112
- const raw = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
113
- return extractAllRelationships(raw);
137
+ return getEnrichedGraph().relationships;
114
138
  },
115
139
 
116
140
  /**
@@ -118,9 +142,7 @@ function schemaExplorerPlugin(options = {}) {
118
142
  * @returns {string}
119
143
  */
120
144
  getMermaid() {
121
- const raw = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
122
- const rels = extractAllRelationships(raw);
123
- return generateMermaidErDiagram(raw, rels);
145
+ return getEnrichedGraph().mermaid;
124
146
  },
125
147
 
126
148
  extractAllRelationships,
@@ -250,21 +272,17 @@ function schemaExplorerPlugin(options = {}) {
250
272
  method: 'get',
251
273
  path: '',
252
274
  handler: (req, res) => {
253
- const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
254
- const relationships = extractAllRelationships(rawModels);
255
- const models = enrichModelsWithGraph(rawModels, relationships);
256
- const mermaid = generateMermaidErDiagram(rawModels, relationships);
257
-
275
+ const graph = getEnrichedGraph();
258
276
  res.json({
259
277
  meta: {
260
278
  version: '1.0.0',
261
279
  generatedAt: new Date().toISOString(),
262
- modelCount: models.length,
263
- relationCount: relationships.length,
280
+ modelCount: graph.enriched.length,
281
+ relationCount: graph.relationships.length,
264
282
  },
265
- models,
266
- relationships,
267
- mermaid,
283
+ models: graph.enriched,
284
+ relationships: graph.relationships,
285
+ mermaid: graph.mermaid,
268
286
  });
269
287
  },
270
288
  },
@@ -272,19 +290,14 @@ function schemaExplorerPlugin(options = {}) {
272
290
  method: 'get',
273
291
  path: '/relations',
274
292
  handler: (req, res) => {
275
- const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
276
- const relationships = extractAllRelationships(rawModels);
277
- res.json({ relationships });
293
+ res.json({ relationships: getEnrichedGraph().relationships });
278
294
  },
279
295
  },
280
296
  {
281
297
  method: 'get',
282
298
  path: '/mermaid',
283
299
  handler: (req, res) => {
284
- const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
285
- const relationships = extractAllRelationships(rawModels);
286
- const mermaid = generateMermaidErDiagram(rawModels, relationships);
287
- res.json({ mermaid });
300
+ res.json({ mermaid: getEnrichedGraph().mermaid });
288
301
  },
289
302
  },
290
303
  {
@@ -298,10 +311,8 @@ function schemaExplorerPlugin(options = {}) {
298
311
  return res.status(404).json({ error: `Model "${modelName}" not found` });
299
312
  }
300
313
 
301
- const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
302
- const relationships = extractAllRelationships(rawModels);
303
- const models = enrichModelsWithGraph(rawModels, relationships);
304
- const found = models.find(m => m.name === modelName);
314
+ const graph = getEnrichedGraph();
315
+ const found = graph.enriched.find(m => m.name === modelName);
305
316
 
306
317
  res.json({ model: found });
307
318
  },
@@ -50,12 +50,15 @@ function seoCheckerPlugin(options = {}) {
50
50
  // Inject panel styles into head
51
51
  ctx.injectHead(generatePanelStyles(), { priority: 100 });
52
52
 
53
+ const safeSettings = JSON.stringify(mergedSettings).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
54
+ const safeChecks = JSON.stringify(checkDefinitions).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
55
+
53
56
  // Inject panel HTML and analyzer script into body
54
57
  ctx.injectBody(`
55
58
  ${generatePanelHtml(checkDefinitions)}
56
59
  <script>
57
- window.__SEO_CHECKER_SETTINGS__ = ${JSON.stringify(mergedSettings)};
58
- window.__SEO_CHECKER_CHECKS__ = ${JSON.stringify(checkDefinitions)};
60
+ window.__SEO_CHECKER_SETTINGS__ = ${safeSettings};
61
+ window.__SEO_CHECKER_CHECKS__ = ${safeChecks};
59
62
  </script>
60
63
  <script>${analyzerScript}</script>
61
64
  `);
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  const crypto = require('crypto');
8
+ const { getClientIp: resolveClientIp } = require('../../core/utils/ip');
8
9
  const { detectBot } = require('./bot-patterns');
9
10
  const { detectCountry } = require('./geo');
10
11
 
@@ -82,17 +83,13 @@ function quickHash(str) {
82
83
  * Get client IP from request, handling proxies
83
84
  */
84
85
  function getClientIp(req) {
85
- const forwarded = req.headers['x-forwarded-for'];
86
- if (forwarded) {
87
- return forwarded.split(',')[0].trim();
88
- }
89
- return req.headers['x-real-ip'] || req.ip || req.connection?.remoteAddress || '';
86
+ return resolveClientIp(req, '');
90
87
  }
91
88
 
92
89
  /**
93
90
  * Create tracking middleware
94
- * @param {Object} options
95
- * @param {Object} options.knex - Knex instance
91
+ * @param {Object} [options={}]
92
+ * @param {Object} [options.knex] - Knex instance
96
93
  * @param {string[]} [options.excludePaths] - Paths to exclude from tracking
97
94
  * @param {boolean} [options.trackBots=true] - Whether to record bot visits
98
95
  * @param {string} [options.tableName='analytics_page_views'] - DB table name
@@ -103,7 +100,8 @@ function getClientIp(req) {
103
100
  * @param {number} [options.maxQueueSize=5000] - Queue memory cap to prevent OOM
104
101
  * @param {number} [options.errorLogIntervalMs=60000] - Rate limit window for batch failure logs
105
102
  */
106
- function createTrackingMiddleware(options) {
103
+ function createTrackingMiddleware(options = {}) {
104
+ const opts = options || {};
107
105
  const {
108
106
  knex,
109
107
  excludePaths = [],
@@ -115,7 +113,7 @@ function createTrackingMiddleware(options) {
115
113
  maxBackoffMs = 60000,
116
114
  maxQueueSize = 5000,
117
115
  errorLogIntervalMs = 60000,
118
- } = options;
116
+ } = opts;
119
117
 
120
118
  const allExcludes = [...DEFAULT_EXCLUDE, ...excludePaths];
121
119
  let tableReady = false;
@@ -213,7 +211,7 @@ function createTrackingMiddleware(options) {
213
211
 
214
212
  async function flushQueue() {
215
213
  if (flushPromise) return flushPromise;
216
- if (queue.length === 0) return;
214
+ if (queue.length === 0 || !knex) return;
217
215
 
218
216
  const now = Date.now();
219
217
  if (now < nextAllowedFlushTime) {
@@ -299,16 +297,17 @@ function createTrackingMiddleware(options) {
299
297
  if (periodicFlushInterval.unref) periodicFlushInterval.unref();
300
298
 
301
299
  async function trackingMiddleware(req, res, next) {
302
- next();
300
+ if (typeof next === 'function') next();
303
301
 
304
- if (req.method !== 'GET') return;
302
+ if (!req || req.method !== 'GET') return;
305
303
 
306
- const path = req.path;
307
- if (STATIC_EXT.test(path)) return;
304
+ const path = req.path || req.url || '';
305
+ if (!path || STATIC_EXT.test(path)) return;
308
306
  if (allExcludes.some(prefix => path.startsWith(prefix))) return;
309
307
 
310
308
  try {
311
- const userAgent = req.headers['user-agent'] || '';
309
+ const headers = req.headers || {};
310
+ const userAgent = headers['user-agent'] || '';
312
311
  const ip = getClientIp(req);
313
312
  const { isBot, botName } = detectBot(userAgent);
314
313
 
@@ -329,7 +328,7 @@ function createTrackingMiddleware(options) {
329
328
  : getSessionId(visitorId);
330
329
 
331
330
  const country = detectCountry(req);
332
- const referrer = req.headers['referer'] || req.headers['referrer'] || null;
331
+ const referrer = headers['referer'] || headers['referrer'] || null;
333
332
 
334
333
  if (queue.length < maxQueueSize) {
335
334
  queue.push({
@@ -385,6 +384,10 @@ function createTrackingMiddleware(options) {
385
384
  }
386
385
  clearInterval(sessionCleanupInterval);
387
386
  clearInterval(periodicFlushInterval);
387
+ if (queue.length > 0 && knex) {
388
+ return flushQueue().catch(() => {});
389
+ }
390
+ return Promise.resolve();
388
391
  };
389
392
 
390
393
  return trackingMiddleware;
@@ -157,8 +157,9 @@ function sitemapPlugin(options = {}) {
157
157
  const dynamicExclusions = [...exclude];
158
158
  const registeredSources = [...dynamicSources];
159
159
 
160
- // Cached URLs (for dynamic sources)
160
+ // Cached URLs and pre-rendered XML (for dynamic sources)
161
161
  let cachedUrls = null;
162
+ let cachedXml = null;
162
163
  let cacheTime = null;
163
164
  const cacheMaxAge = options.cacheMaxAge || 5 * 60 * 1000; // 5 minutes default
164
165
 
@@ -326,7 +327,8 @@ function sitemapPlugin(options = {}) {
326
327
  */
327
328
  addUrl(path, opts = {}) {
328
329
  dynamicUrls.push({ path, ...opts });
329
- cachedUrls = null; // Invalidate cache
330
+ cachedUrls = null;
331
+ cachedXml = null;
330
332
  },
331
333
 
332
334
  /**
@@ -335,7 +337,8 @@ function sitemapPlugin(options = {}) {
335
337
  */
336
338
  exclude(pattern) {
337
339
  dynamicExclusions.push(pattern);
338
- cachedUrls = null; // Invalidate cache
340
+ cachedUrls = null;
341
+ cachedXml = null;
339
342
  },
340
343
 
341
344
  /**
@@ -347,7 +350,8 @@ function sitemapPlugin(options = {}) {
347
350
  throw new Error('urlPattern is required for dynamic source');
348
351
  }
349
352
  registeredSources.push(source);
350
- cachedUrls = null; // Invalidate cache
353
+ cachedUrls = null;
354
+ cachedXml = null;
351
355
  },
352
356
 
353
357
  /**
@@ -369,6 +373,7 @@ function sitemapPlugin(options = {}) {
369
373
  */
370
374
  invalidateCache() {
371
375
  cachedUrls = null;
376
+ cachedXml = null;
372
377
  cacheTime = null;
373
378
  }
374
379
  },
@@ -384,28 +389,26 @@ function sitemapPlugin(options = {}) {
384
389
  try {
385
390
  // Check cache
386
391
  const now = Date.now();
387
- if (cachedUrls && cacheTime && (now - cacheTime) < cacheMaxAge) {
392
+ if (cachedXml && cacheTime && (now - cacheTime) < cacheMaxAge) {
388
393
  res.type('application/xml');
389
- return res.send(generateSitemapXml(cachedUrls, {
390
- hostname,
391
- defaultChangefreq: changefreq,
392
- defaultPriority: priority
393
- }));
394
+ return res.send(cachedXml);
394
395
  }
395
396
 
396
397
  // Build URLs
397
398
  const urls = await buildAllUrls(ctx.routes, dbInstance);
399
+ const xml = generateSitemapXml(urls, {
400
+ hostname,
401
+ defaultChangefreq: changefreq,
402
+ defaultPriority: priority
403
+ });
398
404
 
399
405
  // Cache results
400
406
  cachedUrls = urls;
407
+ cachedXml = xml;
401
408
  cacheTime = now;
402
409
 
403
410
  res.type('application/xml');
404
- res.send(generateSitemapXml(urls, {
405
- hostname,
406
- defaultChangefreq: changefreq,
407
- defaultPriority: priority
408
- }));
411
+ res.send(xml);
409
412
  } catch (err) {
410
413
  console.error('Sitemap generation error:', err);
411
414
  res.status(500).send('Error generating sitemap');
@@ -11,20 +11,7 @@ const { buildOpenApiDocument } = require('../core/openapi/build-from-api-routes'
11
11
 
12
12
  const PKG = require(path.join(__dirname, '..', 'package.json'));
13
13
 
14
- /**
15
- * HTML Escaping
16
- * @param {string} str
17
- * @returns {string}
18
- */
19
- function escapeHtml(str) {
20
- if (!str) return '';
21
- return String(str)
22
- .replace(/&/g, '&amp;')
23
- .replace(/</g, '&lt;')
24
- .replace(/>/g, '&gt;')
25
- .replace(/"/g, '&quot;')
26
- .replace(/'/g, '&#039;');
27
- }
14
+ const { escapeHtml } = require('../core/utils/html');
28
15
 
29
16
  /**
30
17
  * Generates Swagger UI HTML page
@@ -258,10 +245,15 @@ function swaggerPlugin(options = {}) {
258
245
 
259
246
  const server = serverUrl || process.env.BASE_URL || 'http://localhost:3000';
260
247
 
248
+ let cachedDoc = null;
249
+
261
250
  // 1. OpenAPI 3 JSON Endpoint
262
251
  ctx.addRoute('get', jsonPath, (req, res) => {
263
252
  if (forbid(req, res)) return;
264
253
  try {
254
+ if (cachedDoc && process.env.NODE_ENV === 'production') {
255
+ return res.json(cachedDoc);
256
+ }
265
257
  const doc = buildOpenApiDocument({
266
258
  routes: ctx.routes || [],
267
259
  pagesDir: ctx.options.pagesDir,
@@ -274,6 +266,9 @@ function swaggerPlugin(options = {}) {
274
266
  },
275
267
  servers: [{ url: server.replace(/\/$/, '') }],
276
268
  });
269
+ if (process.env.NODE_ENV === 'production') {
270
+ cachedDoc = doc;
271
+ }
277
272
  res.json(doc);
278
273
  } catch (err) {
279
274
  console.warn('[swagger] OpenAPI generation failed:', err.message);
@@ -20,24 +20,7 @@ function generateSecret(prefix = 'whsec_', byteLength = 24) {
20
20
  return `${prefix || ''}${bytes}`;
21
21
  }
22
22
 
23
- /**
24
- * Perform constant-time string comparison to prevent timing attacks
25
- *
26
- * @param {string} a
27
- * @param {string} b
28
- * @returns {boolean}
29
- */
30
- function safeCompare(a, b) {
31
- if (typeof a !== 'string' || typeof b !== 'string') {
32
- return false;
33
- }
34
- const bufA = Buffer.from(a, 'utf8');
35
- const bufB = Buffer.from(b, 'utf8');
36
- if (bufA.length !== bufB.length) {
37
- return false;
38
- }
39
- return crypto.timingSafeEqual(bufA, bufB);
40
- }
23
+ const { safeCompare } = require('../../core/utils/crypto');
41
24
 
42
25
  /**
43
26
  * Sign a webhook payload using HMAC-SHA256
@@ -461,9 +461,14 @@ class WebhookDispatcher extends EventEmitter {
461
461
  attempt: attempt + 1,
462
462
  autoRetry: true,
463
463
  }).catch((err) => {
464
- this.emit('error', err);
464
+ if (this.listenerCount('error') > 0) {
465
+ this.emit('error', err);
466
+ } else {
467
+ console.error('[webhooks] Delivery retry failed:', err?.message || err);
468
+ }
465
469
  });
466
470
  }, delayMs);
471
+ if (timer.unref) timer.unref();
467
472
  this._activeTimers.add(timer);
468
473
  }
469
474
  } else {