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.
- package/bin/commands/dev.js +3 -2
- package/bin/utils/orm-map-html.js +2 -8
- package/core/auth/jwt.js +2 -4
- package/core/auth/oauth.js +38 -14
- package/core/auth/totp.js +3 -15
- package/core/auth/user-agent.js +1 -21
- package/core/content/renderer.js +1 -13
- package/core/errors/middleware.js +2 -8
- package/core/forms/form-builder.js +1 -9
- package/core/orm/index.js +5 -0
- package/core/orm/query-filters.js +393 -0
- package/core/shutdown/shutdown-manager.js +16 -16
- package/core/utils/crypto.js +73 -0
- package/core/utils/escape-html.js +7 -0
- package/core/utils/fetch.js +120 -0
- package/core/utils/get-client-ip.js +6 -0
- package/core/utils/html.js +72 -0
- package/core/utils/ip.js +86 -0
- package/core/utils/safe-compare.js +3 -0
- package/index.d.ts +11 -1
- package/index.js +10 -0
- package/package.json +1 -1
- package/plugins/admin-panel/auth.js +15 -7
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
- package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
- package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
- package/plugins/admin-panel/core/api-extensions.js +1 -75
- package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
- package/plugins/api-key/crypto.js +1 -24
- package/plugins/audit-log/api-handlers.js +11 -1
- package/plugins/audit-log/middleware.js +11 -2
- package/plugins/basic-auth/index.js +2 -26
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +3 -15
- package/plugins/file-manager/admin-component.js +3 -1
- package/plugins/file-manager/core.js +34 -4
- package/plugins/mcp/auth.js +1 -13
- package/plugins/mcp/index.js +23 -1
- package/plugins/mcp/transports/sse.js +10 -1
- package/plugins/orm-cache-admin/admin-component.js +3 -2
- package/plugins/polar/src/api.js +9 -2
- package/plugins/polar/src/webhooks.js +3 -8
- package/plugins/recaptcha/helpers.js +1 -7
- package/plugins/recaptcha/verify.js +37 -23
- package/plugins/redirect/index.js +51 -3
- package/plugins/rest-resources/index.js +1 -207
- package/plugins/schema-explorer/admin-component.js +4 -2
- package/plugins/schema-explorer.js +40 -29
- package/plugins/seo-checker/index.js +5 -2
- package/plugins/site-analytics/tracking.js +19 -16
- package/plugins/sitemap.js +18 -15
- package/plugins/swagger.js +9 -14
- package/plugins/webhooks/crypto.js +1 -18
- package/plugins/webhooks/dispatcher.js +6 -1
- package/plugins/webhooks/matcher.js +25 -10
- package/plugins/webhooks/sender.js +32 -2
- package/src/dev-reload/index.js +47 -6
- package/src/helpers.js +21 -9
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
302
|
-
const
|
|
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__ = ${
|
|
58
|
-
window.__SEO_CHECKER_CHECKS__ = ${
|
|
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
|
-
|
|
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
|
-
} =
|
|
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
|
|
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 =
|
|
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;
|
package/plugins/sitemap.js
CHANGED
|
@@ -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;
|
|
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;
|
|
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;
|
|
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 (
|
|
392
|
+
if (cachedXml && cacheTime && (now - cacheTime) < cacheMaxAge) {
|
|
388
393
|
res.type('application/xml');
|
|
389
|
-
return res.send(
|
|
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(
|
|
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');
|
package/plugins/swagger.js
CHANGED
|
@@ -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, '&')
|
|
23
|
-
.replace(/</g, '<')
|
|
24
|
-
.replace(/>/g, '>')
|
|
25
|
-
.replace(/"/g, '"')
|
|
26
|
-
.replace(/'/g, ''');
|
|
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.
|
|
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 {
|