webspresso 0.0.102 → 0.0.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/ORM.md +13 -4
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
- package/README.md +2 -2
- package/bin/commands/dev.js +3 -2
- package/bin/utils/orm-map-html.js +2 -8
- package/core/auth/jwt.js +12 -21
- package/core/auth/oauth.js +38 -14
- package/core/auth/totp.js +3 -15
- package/core/auth/user-agent.js +59 -60
- package/core/compression/compression-stream.js +2 -2
- package/core/compression/middleware.js +1 -1
- package/core/compression/negotiate.js +11 -4
- package/core/content/renderer.js +1 -13
- package/core/errors/middleware.js +2 -8
- package/core/events/dispatcher.js +56 -33
- package/core/forms/form-builder.js +1 -9
- package/core/openapi/orm-components.js +2 -3
- package/core/orm/cache/listeners.js +12 -2
- package/core/orm/index.js +5 -0
- package/core/orm/json-fields.js +7 -1
- package/core/orm/model.js +99 -8
- package/core/orm/query-builder.js +71 -25
- package/core/orm/query-filters.js +393 -0
- package/core/orm/repository.js +42 -17
- package/core/orm/schema-helpers.js +49 -0
- package/core/orm/scopes.js +40 -9
- package/core/orm/types.js +12 -1
- package/core/queue/manager.js +5 -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/core/validation/index.js +9 -3
- package/index.d.ts +33 -6
- package/index.js +10 -0
- package/package.json +2 -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 +50 -6
- package/plugins/basic-auth/index.js +2 -26
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +3 -15
- package/plugins/email/service.js +32 -1
- package/plugins/file-manager/admin-component.js +3 -1
- package/plugins/file-manager/api-handlers.js +10 -17
- package/plugins/file-manager/core.js +34 -4
- package/plugins/file-manager/index.js +11 -9
- package/plugins/file-manager/security.js +18 -6
- 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/bot-patterns.js +3 -0
- package/plugins/site-analytics/client-error-handler.js +3 -1
- package/plugins/site-analytics/index.js +10 -1
- package/plugins/site-analytics/tracking.js +265 -63
- 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/api/api-loader.js +1 -1
- package/src/dev-reload/index.js +47 -6
- package/src/discovery/scan-routes.js +14 -4
- package/src/file-router.js +62 -6
- package/src/helpers.js +157 -32
- package/src/plugin-manager.js +34 -13
- package/src/routing/route-table.js +14 -4
- package/src/server.js +25 -9
- package/src/services/validator.js +57 -32
|
@@ -5,12 +5,73 @@
|
|
|
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
|
|
|
11
12
|
const STATIC_EXT = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|webp|avif|mp4|webm)$/i;
|
|
12
13
|
const DEFAULT_EXCLUDE = ['/_admin', '/api/', '/health', '/_next', '/__'];
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Permanent / Fatal DB error codes that cannot be recovered by immediate retries.
|
|
17
|
+
*/
|
|
18
|
+
const PERMANENT_ERROR_CODES = new Set([
|
|
19
|
+
'SQLITE_FULL',
|
|
20
|
+
'SQLITE_IOERR',
|
|
21
|
+
'SQLITE_CORRUPT',
|
|
22
|
+
'SQLITE_CANTOPEN',
|
|
23
|
+
'SQLITE_NOTADB',
|
|
24
|
+
'ER_DISK_FULL',
|
|
25
|
+
'ENOSPC',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Patterns matching fatal disk full or corrupted database errors.
|
|
30
|
+
*/
|
|
31
|
+
const PERMANENT_ERROR_PATTERNS = [
|
|
32
|
+
/database or disk is full/i,
|
|
33
|
+
/disk full/i,
|
|
34
|
+
/no space left on device/i,
|
|
35
|
+
/disk i\/o error/i,
|
|
36
|
+
/database disk image is malformed/i,
|
|
37
|
+
/file is not a database/i,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Sanitize and format database error messages to prevent raw SQL query leakage in logs.
|
|
42
|
+
* Knex appends executed queries ("insert into ... - <actual error>") which can be 50-100 KB.
|
|
43
|
+
* @param {Error|Object|string} e
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function formatDbError(e) {
|
|
47
|
+
if (!e) return 'Unknown error';
|
|
48
|
+
|
|
49
|
+
let msg;
|
|
50
|
+
if (typeof e.message === 'string' && e.message.includes(' - ')) {
|
|
51
|
+
msg = e.message.split(' - ').pop().trim();
|
|
52
|
+
} else if (typeof e.message === 'string') {
|
|
53
|
+
msg = e.message.trim();
|
|
54
|
+
} else if (typeof e === 'string') {
|
|
55
|
+
msg = e.trim();
|
|
56
|
+
} else {
|
|
57
|
+
msg = String(e.code || 'Database error');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect whether a database error is permanent/fatal (e.g. disk full, corruption).
|
|
65
|
+
* @param {Error|Object|string} e
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
function isPermanentDbError(e) {
|
|
69
|
+
if (!e) return false;
|
|
70
|
+
if (e.code && PERMANENT_ERROR_CODES.has(e.code)) return true;
|
|
71
|
+
const msg = typeof e.message === 'string' ? e.message : (typeof e === 'string' ? e : '');
|
|
72
|
+
return PERMANENT_ERROR_PATTERNS.some((pattern) => pattern.test(msg));
|
|
73
|
+
}
|
|
74
|
+
|
|
14
75
|
/**
|
|
15
76
|
* Hash a string with SHA-256 (truncated to 16 hex chars for compactness)
|
|
16
77
|
*/
|
|
@@ -22,24 +83,25 @@ function quickHash(str) {
|
|
|
22
83
|
* Get client IP from request, handling proxies
|
|
23
84
|
*/
|
|
24
85
|
function getClientIp(req) {
|
|
25
|
-
|
|
26
|
-
if (forwarded) {
|
|
27
|
-
return forwarded.split(',')[0].trim();
|
|
28
|
-
}
|
|
29
|
-
return req.headers['x-real-ip'] || req.ip || req.connection?.remoteAddress || '';
|
|
86
|
+
return resolveClientIp(req, '');
|
|
30
87
|
}
|
|
31
88
|
|
|
32
89
|
/**
|
|
33
90
|
* Create tracking middleware
|
|
34
|
-
* @param {Object} options
|
|
35
|
-
* @param {Object} options.knex - Knex instance
|
|
91
|
+
* @param {Object} [options={}]
|
|
92
|
+
* @param {Object} [options.knex] - Knex instance
|
|
36
93
|
* @param {string[]} [options.excludePaths] - Paths to exclude from tracking
|
|
37
94
|
* @param {boolean} [options.trackBots=true] - Whether to record bot visits
|
|
38
95
|
* @param {string} [options.tableName='analytics_page_views'] - DB table name
|
|
39
96
|
* @param {number} [options.batchSize=20] - Flush when queue reaches this size
|
|
40
97
|
* @param {number} [options.flushIntervalMs=3000] - Flush interval for low traffic
|
|
98
|
+
* @param {number} [options.maxRetries=3] - Maximum retry attempts per batch item before dropping
|
|
99
|
+
* @param {number} [options.maxBackoffMs=60000] - Max exponential backoff cooldown
|
|
100
|
+
* @param {number} [options.maxQueueSize=5000] - Queue memory cap to prevent OOM
|
|
101
|
+
* @param {number} [options.errorLogIntervalMs=60000] - Rate limit window for batch failure logs
|
|
41
102
|
*/
|
|
42
|
-
function createTrackingMiddleware(options) {
|
|
103
|
+
function createTrackingMiddleware(options = {}) {
|
|
104
|
+
const opts = options || {};
|
|
43
105
|
const {
|
|
44
106
|
knex,
|
|
45
107
|
excludePaths = [],
|
|
@@ -47,12 +109,26 @@ function createTrackingMiddleware(options) {
|
|
|
47
109
|
tableName = 'analytics_page_views',
|
|
48
110
|
batchSize = 20,
|
|
49
111
|
flushIntervalMs = 3000,
|
|
50
|
-
|
|
112
|
+
maxRetries = 3,
|
|
113
|
+
maxBackoffMs = 60000,
|
|
114
|
+
maxQueueSize = 5000,
|
|
115
|
+
errorLogIntervalMs = 60000,
|
|
116
|
+
} = opts;
|
|
51
117
|
|
|
52
118
|
const allExcludes = [...DEFAULT_EXCLUDE, ...excludePaths];
|
|
53
119
|
let tableReady = false;
|
|
54
120
|
const queue = [];
|
|
55
|
-
|
|
121
|
+
|
|
122
|
+
// Circuit breaker & backoff state
|
|
123
|
+
let failureCount = 0;
|
|
124
|
+
let nextAllowedFlushTime = 0;
|
|
125
|
+
let isFlushing = false;
|
|
126
|
+
let flushPromise = null;
|
|
127
|
+
let flushTimeout = null;
|
|
128
|
+
|
|
129
|
+
// Log throttling state
|
|
130
|
+
let lastErrorLogTime = 0;
|
|
131
|
+
let suppressedErrorCount = 0;
|
|
56
132
|
|
|
57
133
|
async function ensureTable() {
|
|
58
134
|
if (tableReady) return;
|
|
@@ -75,7 +151,26 @@ function createTrackingMiddleware(options) {
|
|
|
75
151
|
}
|
|
76
152
|
tableReady = true;
|
|
77
153
|
} catch (e) {
|
|
78
|
-
|
|
154
|
+
logError(e, 'Failed to ensure table');
|
|
155
|
+
throw e;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function logError(e, prefix = 'Batch insert failed') {
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
const formatted = formatDbError(e);
|
|
162
|
+
|
|
163
|
+
if (lastErrorLogTime === 0 || now - lastErrorLogTime >= errorLogIntervalMs) {
|
|
164
|
+
const elapsedSec = lastErrorLogTime > 0 ? Math.round((now - lastErrorLogTime) / 1000) : 0;
|
|
165
|
+
if (suppressedErrorCount > 0) {
|
|
166
|
+
console.error(`[site-analytics] ${prefix} (suppressed ${suppressedErrorCount} occurrences in last ${elapsedSec}s): ${formatted}`);
|
|
167
|
+
} else {
|
|
168
|
+
console.error(`[site-analytics] ${prefix}: ${formatted}`);
|
|
169
|
+
}
|
|
170
|
+
lastErrorLogTime = now;
|
|
171
|
+
suppressedErrorCount = 0;
|
|
172
|
+
} else {
|
|
173
|
+
suppressedErrorCount++;
|
|
79
174
|
}
|
|
80
175
|
}
|
|
81
176
|
|
|
@@ -83,17 +178,17 @@ function createTrackingMiddleware(options) {
|
|
|
83
178
|
const sessionMap = new Map();
|
|
84
179
|
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
|
|
85
180
|
const MAX_SESSIONS = 10000;
|
|
86
|
-
const MAX_QUEUE_SIZE = 5000;
|
|
87
181
|
|
|
88
182
|
// Periodic session cleanup
|
|
89
|
-
setInterval(() => {
|
|
183
|
+
const sessionCleanupInterval = setInterval(() => {
|
|
90
184
|
const now = Date.now();
|
|
91
185
|
for (const [vid, data] of sessionMap) {
|
|
92
186
|
if (now - data.lastSeen > SESSION_TIMEOUT * 2) {
|
|
93
187
|
sessionMap.delete(vid);
|
|
94
188
|
}
|
|
95
189
|
}
|
|
96
|
-
}, 5 * 60 * 1000)
|
|
190
|
+
}, 5 * 60 * 1000);
|
|
191
|
+
if (sessionCleanupInterval.unref) sessionCleanupInterval.unref();
|
|
97
192
|
|
|
98
193
|
function getSessionId(visitorId) {
|
|
99
194
|
const now = Date.now();
|
|
@@ -115,86 +210,193 @@ function createTrackingMiddleware(options) {
|
|
|
115
210
|
}
|
|
116
211
|
|
|
117
212
|
async function flushQueue() {
|
|
118
|
-
if (
|
|
119
|
-
|
|
213
|
+
if (flushPromise) return flushPromise;
|
|
214
|
+
if (queue.length === 0 || !knex) return;
|
|
120
215
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
if (now < nextAllowedFlushTime) {
|
|
218
|
+
scheduleFlush(nextAllowedFlushTime - now);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
isFlushing = true;
|
|
223
|
+
flushPromise = (async () => {
|
|
224
|
+
const batch = queue.splice(0, queue.length);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await ensureTable();
|
|
228
|
+
const dbBatch = batch.map(({ _retries, ...row }) => ({
|
|
229
|
+
...row,
|
|
230
|
+
created_at: knex.fn.now(),
|
|
231
|
+
}));
|
|
232
|
+
await knex(tableName).insert(dbBatch);
|
|
233
|
+
|
|
234
|
+
// Successfully inserted: reset circuit breaker & failure counters
|
|
235
|
+
failureCount = 0;
|
|
236
|
+
nextAllowedFlushTime = 0;
|
|
237
|
+
lastErrorLogTime = 0;
|
|
238
|
+
suppressedErrorCount = 0;
|
|
239
|
+
} catch (e) {
|
|
240
|
+
failureCount++;
|
|
241
|
+
const backoffDelay = Math.min(flushIntervalMs * Math.pow(2, failureCount), maxBackoffMs);
|
|
242
|
+
nextAllowedFlushTime = Date.now() + backoffDelay;
|
|
243
|
+
|
|
244
|
+
// Rate-limited sanitized logging (no raw SQL query leakage)
|
|
245
|
+
logError(e, 'Batch insert failed');
|
|
246
|
+
|
|
247
|
+
// Schedule next attempt respecting backoff
|
|
248
|
+
scheduleFlush(backoffDelay);
|
|
249
|
+
|
|
250
|
+
// Drop policy: If fatal/permanent error (disk full, corrupted DB, etc.), drop entire batch
|
|
251
|
+
if (!isPermanentDbError(e)) {
|
|
252
|
+
const retryable = [];
|
|
253
|
+
for (const item of batch) {
|
|
254
|
+
item._retries = (item._retries || 0) + 1;
|
|
255
|
+
if (item._retries < maxRetries) {
|
|
256
|
+
retryable.push(item);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Re-queue items that haven't exceeded retry limits, respecting maxQueueSize
|
|
261
|
+
if (retryable.length > 0) {
|
|
262
|
+
const available = Math.max(0, maxQueueSize - queue.length);
|
|
263
|
+
if (available > 0) {
|
|
264
|
+
queue.unshift(...retryable.slice(0, available));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
136
267
|
}
|
|
268
|
+
} finally {
|
|
269
|
+
isFlushing = false;
|
|
270
|
+
flushPromise = null;
|
|
137
271
|
}
|
|
138
|
-
}
|
|
272
|
+
})();
|
|
273
|
+
|
|
274
|
+
return flushPromise;
|
|
139
275
|
}
|
|
140
276
|
|
|
141
|
-
function scheduleFlush() {
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
277
|
+
function scheduleFlush(customDelay) {
|
|
278
|
+
if (flushTimeout || queue.length === 0) return;
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
const delay = customDelay !== undefined
|
|
281
|
+
? customDelay
|
|
282
|
+
: Math.max(flushIntervalMs, nextAllowedFlushTime - now, 0);
|
|
283
|
+
|
|
284
|
+
flushTimeout = setTimeout(() => {
|
|
285
|
+
flushTimeout = null;
|
|
146
286
|
flushQueue().catch(() => {});
|
|
147
|
-
},
|
|
287
|
+
}, delay);
|
|
288
|
+
if (flushTimeout.unref) flushTimeout.unref();
|
|
148
289
|
}
|
|
149
290
|
|
|
150
291
|
// Periodic flush (catches low-traffic case)
|
|
151
|
-
setInterval(() => {
|
|
152
|
-
if (queue.length > 0
|
|
153
|
-
|
|
292
|
+
const periodicFlushInterval = setInterval(() => {
|
|
293
|
+
if (queue.length > 0 && Date.now() >= nextAllowedFlushTime && !isFlushing) {
|
|
294
|
+
flushQueue().catch(() => {});
|
|
295
|
+
}
|
|
296
|
+
}, flushIntervalMs);
|
|
297
|
+
if (periodicFlushInterval.unref) periodicFlushInterval.unref();
|
|
154
298
|
|
|
155
|
-
|
|
156
|
-
next();
|
|
299
|
+
async function trackingMiddleware(req, res, next) {
|
|
300
|
+
if (typeof next === 'function') next();
|
|
157
301
|
|
|
158
|
-
if (req.method !== 'GET') return;
|
|
302
|
+
if (!req || req.method !== 'GET') return;
|
|
159
303
|
|
|
160
|
-
const path = req.path;
|
|
161
|
-
if (STATIC_EXT.test(path)) return;
|
|
304
|
+
const path = req.path || req.url || '';
|
|
305
|
+
if (!path || STATIC_EXT.test(path)) return;
|
|
162
306
|
if (allExcludes.some(prefix => path.startsWith(prefix))) return;
|
|
163
307
|
|
|
164
308
|
try {
|
|
165
|
-
const
|
|
309
|
+
const headers = req.headers || {};
|
|
310
|
+
const userAgent = headers['user-agent'] || '';
|
|
166
311
|
const ip = getClientIp(req);
|
|
167
312
|
const { isBot, botName } = detectBot(userAgent);
|
|
168
313
|
|
|
169
314
|
if (isBot && !trackBots) return;
|
|
170
315
|
|
|
316
|
+
// Protect queue under pressure or during active database failures:
|
|
317
|
+
// Drop bots immediately to preserve queue capacity and avoid wasting DB I/O
|
|
318
|
+
if (isBot && (failureCount > 0 || queue.length >= maxQueueSize * 0.7)) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
171
322
|
const ipHash = quickHash(ip);
|
|
172
323
|
const visitorId = quickHash(ip + '|' + userAgent);
|
|
173
|
-
|
|
324
|
+
|
|
325
|
+
// Do not thrash human sessionMap with bot/crawler visits
|
|
326
|
+
const sessionId = isBot
|
|
327
|
+
? quickHash('bot-' + (botName || 'crawler') + '-' + visitorId)
|
|
328
|
+
: getSessionId(visitorId);
|
|
329
|
+
|
|
174
330
|
const country = detectCountry(req);
|
|
175
|
-
const referrer =
|
|
176
|
-
|
|
177
|
-
queue.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
331
|
+
const referrer = headers['referer'] || headers['referrer'] || null;
|
|
332
|
+
|
|
333
|
+
if (queue.length < maxQueueSize) {
|
|
334
|
+
queue.push({
|
|
335
|
+
session_id: sessionId,
|
|
336
|
+
visitor_id: visitorId,
|
|
337
|
+
path,
|
|
338
|
+
referrer: referrer ? referrer.slice(0, 500) : null,
|
|
339
|
+
user_agent: userAgent.slice(0, 1000),
|
|
340
|
+
ip_hash: ipHash,
|
|
341
|
+
country,
|
|
342
|
+
is_bot: isBot,
|
|
343
|
+
bot_name: botName,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
188
346
|
|
|
189
347
|
if (queue.length >= batchSize) {
|
|
190
|
-
|
|
348
|
+
if (Date.now() >= nextAllowedFlushTime) {
|
|
349
|
+
flushQueue().catch(() => {});
|
|
350
|
+
} else {
|
|
351
|
+
scheduleFlush();
|
|
352
|
+
}
|
|
191
353
|
} else {
|
|
192
354
|
scheduleFlush();
|
|
193
355
|
}
|
|
194
356
|
} catch (e) {
|
|
195
357
|
// Non-blocking: don't let tracking errors affect the app
|
|
196
358
|
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Diagnostic / testing accessors
|
|
362
|
+
trackingMiddleware.flush = () => flushQueue();
|
|
363
|
+
trackingMiddleware.getQueue = () => queue;
|
|
364
|
+
trackingMiddleware.getFailureCount = () => failureCount;
|
|
365
|
+
trackingMiddleware.getNextAllowedFlushTime = () => nextAllowedFlushTime;
|
|
366
|
+
trackingMiddleware.resetState = (opts = {}) => {
|
|
367
|
+
failureCount = 0;
|
|
368
|
+
nextAllowedFlushTime = 0;
|
|
369
|
+
const resetLogs = typeof opts === 'boolean' ? opts : !!opts.resetLogs;
|
|
370
|
+
if (resetLogs) {
|
|
371
|
+
lastErrorLogTime = 0;
|
|
372
|
+
suppressedErrorCount = 0;
|
|
373
|
+
}
|
|
374
|
+
queue.length = 0;
|
|
375
|
+
if (flushTimeout) {
|
|
376
|
+
clearTimeout(flushTimeout);
|
|
377
|
+
flushTimeout = null;
|
|
378
|
+
}
|
|
197
379
|
};
|
|
380
|
+
trackingMiddleware.stop = () => {
|
|
381
|
+
if (flushTimeout) {
|
|
382
|
+
clearTimeout(flushTimeout);
|
|
383
|
+
flushTimeout = null;
|
|
384
|
+
}
|
|
385
|
+
clearInterval(sessionCleanupInterval);
|
|
386
|
+
clearInterval(periodicFlushInterval);
|
|
387
|
+
if (queue.length > 0 && knex) {
|
|
388
|
+
return flushQueue().catch(() => {});
|
|
389
|
+
}
|
|
390
|
+
return Promise.resolve();
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return trackingMiddleware;
|
|
198
394
|
}
|
|
199
395
|
|
|
200
|
-
module.exports = {
|
|
396
|
+
module.exports = {
|
|
397
|
+
createTrackingMiddleware,
|
|
398
|
+
formatDbError,
|
|
399
|
+
isPermanentDbError,
|
|
400
|
+
PERMANENT_ERROR_CODES,
|
|
401
|
+
PERMANENT_ERROR_PATTERNS,
|
|
402
|
+
};
|
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 {
|
|
@@ -48,6 +48,25 @@ function normalizeEvents(events) {
|
|
|
48
48
|
return ['*'];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
const regexCache = new Map();
|
|
52
|
+
const MAX_REGEX_CACHE = 1000;
|
|
53
|
+
|
|
54
|
+
function getPatternRegex(pattern) {
|
|
55
|
+
let cached = regexCache.get(pattern);
|
|
56
|
+
if (!cached) {
|
|
57
|
+
if (regexCache.size >= MAX_REGEX_CACHE) {
|
|
58
|
+
const first = regexCache.keys().next().value;
|
|
59
|
+
if (first !== undefined) regexCache.delete(first);
|
|
60
|
+
}
|
|
61
|
+
const escaped = pattern
|
|
62
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials except *
|
|
63
|
+
.replace(/\*/g, '.*');
|
|
64
|
+
cached = new RegExp(`^${escaped}$`, 'i');
|
|
65
|
+
regexCache.set(pattern, cached);
|
|
66
|
+
}
|
|
67
|
+
return cached;
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
/**
|
|
52
71
|
* Test whether an event pattern matches an actual event name
|
|
53
72
|
*
|
|
@@ -56,12 +75,15 @@ function normalizeEvents(events) {
|
|
|
56
75
|
* @returns {boolean}
|
|
57
76
|
*/
|
|
58
77
|
function matchEvent(pattern, eventName) {
|
|
59
|
-
if (
|
|
78
|
+
if (typeof pattern !== 'string' || typeof eventName !== 'string') {
|
|
60
79
|
return false;
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
const p = pattern.trim();
|
|
64
83
|
const e = eventName.trim();
|
|
84
|
+
if (!p || !e) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
65
87
|
|
|
66
88
|
// 1. Wildcard matches all
|
|
67
89
|
if (p === '*') {
|
|
@@ -73,15 +95,8 @@ function matchEvent(pattern, eventName) {
|
|
|
73
95
|
return true;
|
|
74
96
|
}
|
|
75
97
|
|
|
76
|
-
// 3. Glob / Wildcard conversion
|
|
77
|
-
|
|
78
|
-
// Support prefix, middle, or suffix wildcards safely
|
|
79
|
-
const escaped = p
|
|
80
|
-
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials except *
|
|
81
|
-
.replace(/\*/g, '.*');
|
|
82
|
-
|
|
83
|
-
const regex = new RegExp(`^${escaped}$`, 'i');
|
|
84
|
-
return regex.test(e);
|
|
98
|
+
// 3. Glob / Wildcard conversion with bounded cache
|
|
99
|
+
return getPatternRegex(p).test(e);
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
/**
|