webspresso 0.0.103 → 0.0.105

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 (165) hide show
  1. package/.agents/AGENTS.md +6 -0
  2. package/.agents/ARCHITECTURE.md +23 -0
  3. package/.agents/CACHE.md +147 -0
  4. package/.agents/CLI.md +1 -1
  5. package/.agents/EARLY_HINTS.md +120 -0
  6. package/.agents/HEALTH.md +118 -0
  7. package/.agents/LIMITS.md +109 -0
  8. package/.agents/ORM.md +155 -1
  9. package/.agents/PLUGINS.md +28 -0
  10. package/.agents/RESILIENCE.md +235 -0
  11. package/AGENTS.md +6 -0
  12. package/README.md +2 -2
  13. package/bin/commands/add-docker.js +6 -6
  14. package/bin/commands/dev.js +4 -3
  15. package/bin/commands/new.js +5 -5
  16. package/bin/commands/openapi.js +1 -1
  17. package/bin/commands/start.js +1 -1
  18. package/bin/utils/orm-map-html.js +2 -8
  19. package/bin/utils/project.js +2 -2
  20. package/core/auth/hash.js +5 -4
  21. package/core/auth/jwt.js +6 -57
  22. package/core/auth/magic-link.js +4 -5
  23. package/core/auth/manager.js +3 -5
  24. package/core/auth/oauth.js +45 -30
  25. package/core/auth/totp.js +5 -16
  26. package/core/auth/user-agent.js +1 -21
  27. package/core/cache/byte-size.js +123 -0
  28. package/core/cache/index.js +21 -0
  29. package/core/cache/lru.js +477 -0
  30. package/core/cache/tagged-lru.js +346 -0
  31. package/core/commands/registry.js +6 -20
  32. package/core/content/renderer.js +1 -13
  33. package/core/early-hints/index.js +267 -0
  34. package/core/errors/domain.js +48 -0
  35. package/core/errors/http.js +31 -0
  36. package/core/errors/index.js +10 -0
  37. package/core/errors/middleware.js +2 -8
  38. package/core/errors/normalize.js +88 -2
  39. package/core/events/discovery.js +6 -24
  40. package/core/events/dispatcher.js +2 -7
  41. package/core/forms/form-builder.js +1 -9
  42. package/core/health/index.js +41 -0
  43. package/core/health/middleware.js +213 -0
  44. package/core/health/normalize.js +140 -0
  45. package/core/health/probes.js +203 -0
  46. package/core/limits/index.js +34 -0
  47. package/core/limits/middleware.js +317 -0
  48. package/core/limits/normalize.js +222 -0
  49. package/core/orm/batch.js +70 -0
  50. package/core/orm/cache/fingerprint.js +2 -2
  51. package/core/orm/cache/memory-provider.js +27 -110
  52. package/core/orm/index.js +76 -8
  53. package/core/orm/json-fields.js +22 -8
  54. package/core/orm/model.js +10 -0
  55. package/core/orm/query-builder.js +63 -9
  56. package/core/orm/query-filters.js +393 -0
  57. package/core/orm/repository.js +318 -19
  58. package/core/orm/schema-helpers.js +8 -10
  59. package/core/orm/scopes.js +321 -1
  60. package/core/orm/transaction.js +10 -2
  61. package/core/orm/utils.js +4 -86
  62. package/core/queue/adapters/database.js +4 -8
  63. package/core/queue/manager.js +55 -41
  64. package/core/resilience/circuit-breaker.js +391 -0
  65. package/core/resilience/errors.js +63 -0
  66. package/core/resilience/index.js +53 -0
  67. package/core/resilience/registry.js +97 -0
  68. package/core/resilience/retry.js +203 -0
  69. package/core/schema-cache.js +9 -2
  70. package/core/shutdown/http-adapter.js +4 -1
  71. package/core/shutdown/shutdown-manager.js +132 -31
  72. package/core/utils/array.js +12 -0
  73. package/core/utils/base64url.js +8 -0
  74. package/core/utils/crypto.js +191 -0
  75. package/core/utils/ensure-leading-slash.js +6 -0
  76. package/core/utils/escape-html.js +7 -0
  77. package/core/utils/escape-regexp.js +7 -0
  78. package/core/utils/fetch.js +121 -0
  79. package/core/utils/fs.js +144 -0
  80. package/core/utils/get-client-ip.js +6 -0
  81. package/core/utils/hmac.js +6 -0
  82. package/core/utils/html.js +72 -0
  83. package/core/utils/ip.js +86 -0
  84. package/core/utils/json.js +98 -0
  85. package/core/utils/object.js +106 -0
  86. package/core/utils/random.js +9 -0
  87. package/core/utils/regex.js +77 -0
  88. package/core/utils/safe-compare.js +3 -0
  89. package/core/utils/sha256.js +6 -0
  90. package/core/utils/string.js +87 -0
  91. package/core/utils/strip-trailing-slash.js +6 -0
  92. package/core/utils/time.js +96 -0
  93. package/core/utils/timeout.js +348 -0
  94. package/core/utils/url.js +106 -0
  95. package/core/utils/walk.js +7 -0
  96. package/index.d.ts +627 -2
  97. package/index.js +158 -0
  98. package/package.json +1 -2
  99. package/plugins/admin-panel/auth.js +15 -7
  100. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  101. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  102. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  103. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  104. package/plugins/admin-panel/core/api-extensions.js +3 -80
  105. package/plugins/admin-panel/index.js +1 -1
  106. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  107. package/plugins/admin-panel/modules/menu.js +1 -1
  108. package/plugins/api-key/crypto.js +3 -26
  109. package/plugins/api-key/scopes.js +5 -7
  110. package/plugins/audit-log/api-handlers.js +8 -7
  111. package/plugins/audit-log/middleware.js +11 -2
  112. package/plugins/audit-log/parse.js +3 -3
  113. package/plugins/basic-auth/index.js +2 -26
  114. package/plugins/cors.js +2 -2
  115. package/plugins/csrf/index.js +7 -23
  116. package/plugins/dashboard/app.js +2 -2
  117. package/plugins/dashboard/index.js +5 -5
  118. package/plugins/data-exchange/record-selection.js +2 -5
  119. package/plugins/email/api-handlers.js +5 -18
  120. package/plugins/email/auth-bridge.js +3 -2
  121. package/plugins/file-manager/admin-component.js +3 -1
  122. package/plugins/file-manager/core.js +34 -4
  123. package/plugins/health-check.js +2 -1
  124. package/plugins/index.js +2 -0
  125. package/plugins/lazy.js +5 -6
  126. package/plugins/mcp/auth.js +1 -13
  127. package/plugins/mcp/discovery.js +18 -17
  128. package/plugins/mcp/index.js +23 -1
  129. package/plugins/mcp/transports/sse.js +10 -1
  130. package/plugins/orm-cache-admin/admin-component.js +3 -2
  131. package/plugins/polar/src/api.js +11 -3
  132. package/plugins/polar/src/webhooks.js +7 -13
  133. package/plugins/rate-limit/index.js +3 -1
  134. package/plugins/recaptcha/helpers.js +1 -7
  135. package/plugins/recaptcha/verify.js +37 -23
  136. package/plugins/redirect/index.js +57 -7
  137. package/plugins/rest-resources/index.js +1 -207
  138. package/plugins/rls/index.js +170 -0
  139. package/plugins/schema-explorer/admin-component.js +4 -2
  140. package/plugins/schema-explorer.js +40 -29
  141. package/plugins/seo-checker/index.js +5 -2
  142. package/plugins/site-analytics/tracking.js +21 -18
  143. package/plugins/sitemap.js +21 -17
  144. package/plugins/swagger.js +13 -17
  145. package/plugins/upload/index.js +4 -3
  146. package/plugins/upload/orphan-pruner.js +4 -3
  147. package/plugins/webhooks/crypto.js +6 -28
  148. package/plugins/webhooks/dispatcher.js +14 -9
  149. package/plugins/webhooks/index.js +6 -0
  150. package/plugins/webhooks/matcher.js +28 -17
  151. package/plugins/webhooks/sender.js +40 -13
  152. package/src/app-context.js +41 -5
  153. package/src/dev/banner.js +6 -2
  154. package/src/dev-reload/index.js +47 -6
  155. package/src/file-router.js +1 -3
  156. package/src/helpers.js +180 -33
  157. package/src/modules/module-discovery.js +1 -9
  158. package/src/pages/page-loader.js +53 -1
  159. package/src/plugin-manager.js +90 -17
  160. package/src/server.js +137 -26
  161. package/src/services/discovery.js +19 -40
  162. package/src/services/executor.js +86 -25
  163. package/src/services/memoize.js +3 -25
  164. package/src/services/registry.js +6 -0
  165. package/src/template/precompile.js +8 -19
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Webspresso RLS Plugin — Dynamic Model Row-Level Security
3
+ * Automatically bridges request authentication (req.user, req.auth, req.tenantId)
4
+ * to ORM repositories with role-based bypass and route-level overrides.
5
+ * Zero external dependencies.
6
+ * @module plugins/rls
7
+ */
8
+
9
+ /**
10
+ * Default user extractor from HTTP request
11
+ * @param {import('express').Request} req
12
+ * @returns {Object|null}
13
+ */
14
+ function defaultGetUser(req) {
15
+ if (!req) return null;
16
+ return req.user || req.adminUser || (req.auth && req.auth.user) || null;
17
+ }
18
+
19
+ /**
20
+ * Default tenant extractor from HTTP request
21
+ * @param {import('express').Request} req
22
+ * @param {Object|null} [user]
23
+ * @returns {unknown|null}
24
+ */
25
+ function defaultGetTenantId(req, user) {
26
+ if (!req) return null;
27
+ if (req.tenantId !== undefined && req.tenantId !== null) return req.tenantId;
28
+ if (req.tenant !== undefined && req.tenant !== null) return req.tenant;
29
+ if (user && typeof user === 'object') {
30
+ if (user.tenant_id !== undefined && user.tenant_id !== null) return user.tenant_id;
31
+ if (user.tenantId !== undefined && user.tenantId !== null) return user.tenantId;
32
+ if (user.organization_id !== undefined && user.organization_id !== null) return user.organization_id;
33
+ if (user.organizationId !== undefined && user.organizationId !== null) return user.organizationId;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Create Express middleware for Row Level Security
40
+ * Wraps req.db methods to automatically inject authenticated user and tenant context
41
+ *
42
+ * @param {Object} [options]
43
+ * @param {string[]|Function|false} [options.bypassRoles=['admin', 'superadmin']] - Roles that bypass RLS
44
+ * @param {Function} [options.getUser] - Custom user extractor (req) => user
45
+ * @param {Function} [options.getTenantId] - Custom tenant extractor (req, user) => tenantId
46
+ * @param {boolean} [options.enabled=true] - Whether RLS middleware is active
47
+ * @returns {Function} Express middleware handler
48
+ */
49
+ function createRlsMiddleware(options = {}) {
50
+ const {
51
+ bypassRoles = ['admin', 'superadmin'],
52
+ getUser = defaultGetUser,
53
+ getTenantId = defaultGetTenantId,
54
+ enabled = true,
55
+ } = options;
56
+
57
+ return (req, res, next) => {
58
+ if (!enabled) return next();
59
+
60
+ const user = typeof getUser === 'function' ? getUser(req) : null;
61
+ const tenantId = typeof getTenantId === 'function' ? getTenantId(req, user) : null;
62
+
63
+ // Check if user has global bypass role
64
+ let isBypassed = false;
65
+ if (user && bypassRoles) {
66
+ if (typeof bypassRoles === 'function') {
67
+ isBypassed = Boolean(bypassRoles(user, req));
68
+ } else if (Array.isArray(bypassRoles)) {
69
+ const uRole = user.role || user.roleName || user.type;
70
+ const uRoles = Array.isArray(user.roles) ? user.roles : (uRole ? [uRole] : []);
71
+ isBypassed = bypassRoles.some((r) => uRoles.includes(r));
72
+ }
73
+ }
74
+
75
+ // Wrap req.db if it exists on the request
76
+ if (req.db && typeof req.db.getRepository === 'function' && !req.db._rlsWrapped) {
77
+ const origGetRepo = req.db.getRepository.bind(req.db);
78
+ const origQuery = req.db.query ? req.db.query.bind(req.db) : null;
79
+ const origCreateRepo = req.db.createRepository ? req.db.createRepository.bind(req.db) : null;
80
+
81
+ const reqDb = Object.create(req.db);
82
+ reqDb._rlsWrapped = true;
83
+
84
+ reqDb.getRepository = (modelName, customContext = {}) => {
85
+ const ctx = {
86
+ user,
87
+ tenantId: customContext.tenantId !== undefined ? customContext.tenantId : tenantId,
88
+ rlsDisabled: isBypassed || customContext.rlsDisabled === true,
89
+ ...customContext,
90
+ };
91
+ return origGetRepo(modelName, ctx);
92
+ };
93
+
94
+ if (origCreateRepo) {
95
+ reqDb.createRepository = (model, customContext = {}) => {
96
+ const ctx = {
97
+ user,
98
+ tenantId: customContext.tenantId !== undefined ? customContext.tenantId : tenantId,
99
+ rlsDisabled: isBypassed || customContext.rlsDisabled === true,
100
+ ...customContext,
101
+ };
102
+ return origCreateRepo(model, ctx);
103
+ };
104
+ }
105
+
106
+ if (origQuery) {
107
+ reqDb.query = (modelName, customContext = {}) => {
108
+ const ctx = {
109
+ user,
110
+ tenantId: customContext.tenantId !== undefined ? customContext.tenantId : tenantId,
111
+ rlsDisabled: isBypassed || customContext.rlsDisabled === true,
112
+ ...customContext,
113
+ };
114
+ return origQuery(modelName, ctx);
115
+ };
116
+ }
117
+
118
+ req.db = reqDb;
119
+ }
120
+
121
+ next();
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Webspresso RLS Plugin Factory
127
+ *
128
+ * @param {Object} [options] - Plugin configuration options
129
+ * @param {boolean} [options.global=true] - Mount global middleware on app
130
+ * @param {string[]|Function|false} [options.bypassRoles=['admin', 'superadmin']] - Global roles bypassing RLS
131
+ * @param {Function} [options.getUser] - Custom user extractor
132
+ * @param {Function} [options.getTenantId] - Custom tenant extractor
133
+ * @param {boolean} [options.enabled=true] - Enabled flag
134
+ * @returns {Object} Webspresso Plugin Definition
135
+ */
136
+ function rlsPlugin(options = {}) {
137
+ const { global: applyGlobal = true, ...pluginDefaults } = options;
138
+
139
+ return {
140
+ name: 'rls',
141
+ version: '1.0.0',
142
+ description: 'Dynamic Model Row-Level Security (RLS) with automatic request context binding',
143
+ options,
144
+
145
+ api: {
146
+ createMiddleware: (routeOpts = {}) =>
147
+ createRlsMiddleware({ ...pluginDefaults, ...routeOpts }),
148
+ },
149
+
150
+ register(ctx) {
151
+ // Named middleware for route definitions: middleware: ['rls']
152
+ if (ctx.middlewares) {
153
+ ctx.middlewares.rls = (routeOpts = {}) =>
154
+ createRlsMiddleware({ ...pluginDefaults, ...routeOpts });
155
+ }
156
+
157
+ // Global middleware (mounted by default unless global: false)
158
+ if (applyGlobal && ctx.app) {
159
+ ctx.app.use(createRlsMiddleware(options));
160
+ }
161
+ },
162
+ };
163
+ }
164
+
165
+ rlsPlugin.rlsPlugin = rlsPlugin;
166
+ rlsPlugin.createRlsMiddleware = createRlsMiddleware;
167
+
168
+ module.exports = rlsPlugin;
169
+ module.exports.rlsPlugin = rlsPlugin;
170
+ module.exports.createRlsMiddleware = createRlsMiddleware;
@@ -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
  `);
@@ -4,7 +4,8 @@
4
4
  * @module plugins/site-analytics/tracking
5
5
  */
6
6
 
7
- const crypto = require('crypto');
7
+ const { sha256 } = require('../../core/utils/crypto');
8
+ const { getClientIp: resolveClientIp } = require('../../core/utils/ip');
8
9
  const { detectBot } = require('./bot-patterns');
9
10
  const { detectCountry } = require('./geo');
10
11
 
@@ -75,24 +76,20 @@ function isPermanentDbError(e) {
75
76
  * Hash a string with SHA-256 (truncated to 16 hex chars for compactness)
76
77
  */
77
78
  function quickHash(str) {
78
- return crypto.createHash('sha256').update(str).digest('hex').slice(0, 16);
79
+ return sha256(str).slice(0, 16);
79
80
  }
80
81
 
81
82
  /**
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;
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  const { matchPattern } = require('../src/plugin-manager');
7
+ const { stripTrailingSlash } = require('../core/utils/url');
7
8
 
8
9
  /**
9
10
  * Escape XML special characters
@@ -140,7 +141,7 @@ function generateRobotsTxt(hostname, options = {}) {
140
141
  */
141
142
  function sitemapPlugin(options = {}) {
142
143
  const {
143
- hostname = process.env.BASE_URL || 'http://localhost:3000',
144
+ hostname = process.env.BASE_URL || 'http://localhost:7331',
144
145
  exclude = ['/api/*'],
145
146
  changefreq = 'weekly',
146
147
  priority = 0.8,
@@ -157,8 +158,9 @@ function sitemapPlugin(options = {}) {
157
158
  const dynamicExclusions = [...exclude];
158
159
  const registeredSources = [...dynamicSources];
159
160
 
160
- // Cached URLs (for dynamic sources)
161
+ // Cached URLs and pre-rendered XML (for dynamic sources)
161
162
  let cachedUrls = null;
163
+ let cachedXml = null;
162
164
  let cacheTime = null;
163
165
  const cacheMaxAge = options.cacheMaxAge || 5 * 60 * 1000; // 5 minutes default
164
166
 
@@ -228,7 +230,7 @@ function sitemapPlugin(options = {}) {
228
230
  */
229
231
  async function buildAllUrls(routes, dbInstance) {
230
232
  const urls = [];
231
- const hostnameNormalized = hostname.replace(/\/$/, '');
233
+ const hostnameNormalized = stripTrailingSlash(hostname);
232
234
 
233
235
  // Filter static routes for sitemap
234
236
  const sitemapRoutes = routes
@@ -326,7 +328,8 @@ function sitemapPlugin(options = {}) {
326
328
  */
327
329
  addUrl(path, opts = {}) {
328
330
  dynamicUrls.push({ path, ...opts });
329
- cachedUrls = null; // Invalidate cache
331
+ cachedUrls = null;
332
+ cachedXml = null;
330
333
  },
331
334
 
332
335
  /**
@@ -335,7 +338,8 @@ function sitemapPlugin(options = {}) {
335
338
  */
336
339
  exclude(pattern) {
337
340
  dynamicExclusions.push(pattern);
338
- cachedUrls = null; // Invalidate cache
341
+ cachedUrls = null;
342
+ cachedXml = null;
339
343
  },
340
344
 
341
345
  /**
@@ -347,7 +351,8 @@ function sitemapPlugin(options = {}) {
347
351
  throw new Error('urlPattern is required for dynamic source');
348
352
  }
349
353
  registeredSources.push(source);
350
- cachedUrls = null; // Invalidate cache
354
+ cachedUrls = null;
355
+ cachedXml = null;
351
356
  },
352
357
 
353
358
  /**
@@ -369,6 +374,7 @@ function sitemapPlugin(options = {}) {
369
374
  */
370
375
  invalidateCache() {
371
376
  cachedUrls = null;
377
+ cachedXml = null;
372
378
  cacheTime = null;
373
379
  }
374
380
  },
@@ -384,28 +390,26 @@ function sitemapPlugin(options = {}) {
384
390
  try {
385
391
  // Check cache
386
392
  const now = Date.now();
387
- if (cachedUrls && cacheTime && (now - cacheTime) < cacheMaxAge) {
393
+ if (cachedXml && cacheTime && (now - cacheTime) < cacheMaxAge) {
388
394
  res.type('application/xml');
389
- return res.send(generateSitemapXml(cachedUrls, {
390
- hostname,
391
- defaultChangefreq: changefreq,
392
- defaultPriority: priority
393
- }));
395
+ return res.send(cachedXml);
394
396
  }
395
397
 
396
398
  // Build URLs
397
399
  const urls = await buildAllUrls(ctx.routes, dbInstance);
400
+ const xml = generateSitemapXml(urls, {
401
+ hostname,
402
+ defaultChangefreq: changefreq,
403
+ defaultPriority: priority
404
+ });
398
405
 
399
406
  // Cache results
400
407
  cachedUrls = urls;
408
+ cachedXml = xml;
401
409
  cacheTime = now;
402
410
 
403
411
  res.type('application/xml');
404
- res.send(generateSitemapXml(urls, {
405
- hostname,
406
- defaultChangefreq: changefreq,
407
- defaultPriority: priority
408
- }));
412
+ res.send(xml);
409
413
  } catch (err) {
410
414
  console.error('Sitemap generation error:', err);
411
415
  res.status(500).send('Error generating sitemap');
@@ -11,20 +11,8 @@ 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');
15
+ const { stripTrailingSlash } = require('../core/utils/url');
28
16
 
29
17
  /**
30
18
  * Generates Swagger UI HTML page
@@ -193,7 +181,7 @@ function generateApiDocsAdminComponent({ uiPath, jsonPath, title }) {
193
181
  * @param {string} [options.title] - OpenAPI info.title
194
182
  * @param {string} [options.version] - OpenAPI info.version
195
183
  * @param {string} [options.description] - OpenAPI info.description
196
- * @param {string} [options.serverUrl] - Override servers[0].url (default: BASE_URL or http://localhost:3000)
184
+ * @param {string} [options.serverUrl] - Override servers[0].url (default: BASE_URL or http://localhost:7331)
197
185
  * @param {string} [options.theme='default'] - Theme for Scalar UI
198
186
  * @param {boolean} [options.darkMode=true] - Dark mode default for Scalar UI
199
187
  * @param {boolean} [options.admin=true] - Auto-register in Admin Panel if admin-panel plugin is present
@@ -256,12 +244,17 @@ function swaggerPlugin(options = {}) {
256
244
  return false;
257
245
  };
258
246
 
259
- const server = serverUrl || process.env.BASE_URL || 'http://localhost:3000';
247
+ const server = serverUrl || process.env.BASE_URL || 'http://localhost:7331';
248
+
249
+ let cachedDoc = null;
260
250
 
261
251
  // 1. OpenAPI 3 JSON Endpoint
262
252
  ctx.addRoute('get', jsonPath, (req, res) => {
263
253
  if (forbid(req, res)) return;
264
254
  try {
255
+ if (cachedDoc && process.env.NODE_ENV === 'production') {
256
+ return res.json(cachedDoc);
257
+ }
265
258
  const doc = buildOpenApiDocument({
266
259
  routes: ctx.routes || [],
267
260
  pagesDir: ctx.options.pagesDir,
@@ -272,8 +265,11 @@ function swaggerPlugin(options = {}) {
272
265
  version: docVersion,
273
266
  description: docDescription,
274
267
  },
275
- servers: [{ url: server.replace(/\/$/, '') }],
268
+ servers: [{ url: stripTrailingSlash(server) }],
276
269
  });
270
+ if (process.env.NODE_ENV === 'production') {
271
+ cachedDoc = doc;
272
+ }
277
273
  res.json(doc);
278
274
  } catch (err) {
279
275
  console.warn('[swagger] OpenAPI generation failed:', err.message);
@@ -6,6 +6,8 @@
6
6
  const multer = require('multer');
7
7
  const path = require('path');
8
8
  const { createLocalFileProvider } = require('./local-file-provider');
9
+ const { ensureLeadingSlash } = require('../../core/utils/url');
10
+ const { ensureArray } = require('../../core/utils/object');
9
11
 
10
12
  /**
11
13
  * @typedef {import('express').Request} ExpressRequest
@@ -37,8 +39,7 @@ const { createLocalFileProvider } = require('./local-file-provider');
37
39
  * @returns {string}
38
40
  */
39
41
  function normalizeRoutePath(p) {
40
- const s = (p || '/api/upload').trim();
41
- return s.startsWith('/') ? s : `/${s}`;
42
+ return ensureLeadingSlash((p || '/api/upload').trim());
42
43
  }
43
44
 
44
45
  /**
@@ -118,7 +119,7 @@ function uploadPlugin(options = {}) {
118
119
  publicBasePath: local?.publicBasePath,
119
120
  });
120
121
 
121
- const mw = (Array.isArray(middleware) ? middleware : [middleware]).filter(Boolean);
122
+ const mw = ensureArray(middleware).filter(Boolean);
122
123
  const parseMultipart = createMulterMiddleware(maxBytes, fieldName, multiple, maxFiles);
123
124
 
124
125
  return {
@@ -11,6 +11,7 @@ const fs = require('fs/promises');
11
11
  const fsSync = require('fs');
12
12
  const path = require('path');
13
13
  const { normalizePublicBase } = require('./local-file-provider');
14
+ const { safeJsonParse } = require('../../core/utils/json');
14
15
 
15
16
  /**
16
17
  * Format bytes into human readable string
@@ -137,10 +138,10 @@ async function extractDbFileReferences(db) {
137
138
  for (const [key, value] of Object.entries(row)) {
138
139
  // If value is a JSON string, try to parse
139
140
  if (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))) {
140
- try {
141
- const parsed = JSON.parse(value);
141
+ const parsed = safeJsonParse(value, null);
142
+ if (parsed) {
142
143
  scanValue(parsed);
143
- } catch (e) {
144
+ } else {
144
145
  addStringReference(value);
145
146
  }
146
147
  } else {