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
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso ORM - Universal Query Filter Engine
|
|
3
|
+
*
|
|
4
|
+
* Unified column filtering engine powering both REST Resources and Admin Panel.
|
|
5
|
+
* Supports comparison operators, pattern matching, sets, ranges, nullability,
|
|
6
|
+
* and column type coercion.
|
|
7
|
+
*
|
|
8
|
+
* @module core/orm/query-filters
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const RESERVED_QUERY_KEYS = new Set([
|
|
14
|
+
'page',
|
|
15
|
+
'perPage',
|
|
16
|
+
'per_page',
|
|
17
|
+
'limit',
|
|
18
|
+
'offset',
|
|
19
|
+
'sort',
|
|
20
|
+
'order',
|
|
21
|
+
'include',
|
|
22
|
+
'trashed',
|
|
23
|
+
'fields',
|
|
24
|
+
'select',
|
|
25
|
+
'search',
|
|
26
|
+
'q',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalizes an operator name to canonical form
|
|
31
|
+
* @param {string} op
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function normalizeOperator(op) {
|
|
35
|
+
const s = String(op || 'contains').toLowerCase().trim();
|
|
36
|
+
switch (s) {
|
|
37
|
+
case 'equals':
|
|
38
|
+
case '=':
|
|
39
|
+
case 'eq':
|
|
40
|
+
return 'eq';
|
|
41
|
+
|
|
42
|
+
case 'ne':
|
|
43
|
+
case '!=':
|
|
44
|
+
case '<>':
|
|
45
|
+
case 'not_equals':
|
|
46
|
+
case 'notequals':
|
|
47
|
+
return 'ne';
|
|
48
|
+
|
|
49
|
+
case 'gt':
|
|
50
|
+
case '>':
|
|
51
|
+
return 'gt';
|
|
52
|
+
|
|
53
|
+
case 'gte':
|
|
54
|
+
case '>=':
|
|
55
|
+
return 'gte';
|
|
56
|
+
|
|
57
|
+
case 'lt':
|
|
58
|
+
case '<':
|
|
59
|
+
return 'lt';
|
|
60
|
+
|
|
61
|
+
case 'lte':
|
|
62
|
+
case '<=':
|
|
63
|
+
return 'lte';
|
|
64
|
+
|
|
65
|
+
case 'in':
|
|
66
|
+
return 'in';
|
|
67
|
+
|
|
68
|
+
case 'nin':
|
|
69
|
+
case 'notin':
|
|
70
|
+
case 'not_in':
|
|
71
|
+
return 'notin';
|
|
72
|
+
|
|
73
|
+
case 'like':
|
|
74
|
+
return 'like';
|
|
75
|
+
|
|
76
|
+
case 'ilike':
|
|
77
|
+
return 'ilike';
|
|
78
|
+
|
|
79
|
+
case 'contains':
|
|
80
|
+
return 'contains';
|
|
81
|
+
|
|
82
|
+
case 'starts_with':
|
|
83
|
+
case 'startswith':
|
|
84
|
+
return 'startswith';
|
|
85
|
+
|
|
86
|
+
case 'ends_with':
|
|
87
|
+
case 'endswith':
|
|
88
|
+
return 'endswith';
|
|
89
|
+
|
|
90
|
+
case 'is_null':
|
|
91
|
+
case 'isnull':
|
|
92
|
+
case 'null':
|
|
93
|
+
return 'is_null';
|
|
94
|
+
|
|
95
|
+
case 'is_not_null':
|
|
96
|
+
case 'isnotnull':
|
|
97
|
+
case 'not_null':
|
|
98
|
+
case 'notnull':
|
|
99
|
+
return 'is_not_null';
|
|
100
|
+
|
|
101
|
+
case 'between':
|
|
102
|
+
return 'between';
|
|
103
|
+
|
|
104
|
+
default:
|
|
105
|
+
return s;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Applies a single filter condition to one or more query builder instances
|
|
111
|
+
*
|
|
112
|
+
* @param {Array<Object>} targets - Query builder instances to mutate
|
|
113
|
+
* @param {string} column - Target column name
|
|
114
|
+
* @param {string} rawOp - Operator name
|
|
115
|
+
* @param {unknown} rawVal - Value or object containing from/to
|
|
116
|
+
* @param {Object} [meta] - Optional column metadata from model definition
|
|
117
|
+
*/
|
|
118
|
+
function applyFilterCondition(targets, column, rawOp, rawVal, meta = null) {
|
|
119
|
+
if (!targets || targets.length === 0) return;
|
|
120
|
+
|
|
121
|
+
const colType = meta?.type;
|
|
122
|
+
const operator = normalizeOperator(rawOp);
|
|
123
|
+
let val = rawVal;
|
|
124
|
+
|
|
125
|
+
// Handle boolean column type coercion
|
|
126
|
+
if (colType === 'boolean' && typeof val === 'string') {
|
|
127
|
+
if (val === 'true' || val === '1') {
|
|
128
|
+
val = true;
|
|
129
|
+
} else if (val === 'false' || val === '0') {
|
|
130
|
+
val = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Handle nullability operators
|
|
135
|
+
if (operator === 'is_null') {
|
|
136
|
+
const isTrue = val === undefined || val === null || val === true || val === 'true' || val === '1' || val === 1 || val === '';
|
|
137
|
+
if (isTrue) {
|
|
138
|
+
for (const t of targets) t.whereNull(column);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (operator === 'is_not_null') {
|
|
144
|
+
const isTrue = val === undefined || val === null || val === true || val === 'true' || val === '1' || val === 1 || val === '';
|
|
145
|
+
if (isTrue) {
|
|
146
|
+
for (const t of targets) t.whereNotNull(column);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Handle between operator
|
|
152
|
+
if (operator === 'between') {
|
|
153
|
+
let from = undefined;
|
|
154
|
+
let to = undefined;
|
|
155
|
+
|
|
156
|
+
if (Array.isArray(val)) {
|
|
157
|
+
from = val[0];
|
|
158
|
+
to = val[1];
|
|
159
|
+
} else if (typeof val === 'string') {
|
|
160
|
+
const parts = val.split(',').map((s) => s.trim());
|
|
161
|
+
from = parts[0];
|
|
162
|
+
to = parts[1];
|
|
163
|
+
} else if (val && typeof val === 'object') {
|
|
164
|
+
from = val.from;
|
|
165
|
+
to = val.to;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const hasFrom = from !== undefined && from !== null && from !== '';
|
|
169
|
+
const hasTo = to !== undefined && to !== null && to !== '';
|
|
170
|
+
|
|
171
|
+
if (hasFrom && hasTo) {
|
|
172
|
+
for (const t of targets) {
|
|
173
|
+
t.where(column, '>=', from);
|
|
174
|
+
t.where(column, '<=', to);
|
|
175
|
+
}
|
|
176
|
+
} else if (hasFrom) {
|
|
177
|
+
for (const t of targets) t.where(column, '>=', from);
|
|
178
|
+
} else if (hasTo) {
|
|
179
|
+
for (const t of targets) t.where(column, '<=', to);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Handle in & notin
|
|
185
|
+
if (operator === 'in') {
|
|
186
|
+
const arr = Array.isArray(val)
|
|
187
|
+
? val
|
|
188
|
+
: (typeof val === 'string' ? val.split(',').map((s) => s.trim()).filter(Boolean) : (val !== undefined && val !== '' ? [val] : []));
|
|
189
|
+
if (arr.length > 0) {
|
|
190
|
+
for (const t of targets) t.whereIn(column, arr);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (operator === 'notin') {
|
|
196
|
+
const arr = Array.isArray(val)
|
|
197
|
+
? val
|
|
198
|
+
: (typeof val === 'string' ? val.split(',').map((s) => s.trim()).filter(Boolean) : (val !== undefined && val !== '' ? [val] : []));
|
|
199
|
+
if (arr.length > 0) {
|
|
200
|
+
for (const t of targets) t.whereNotIn(column, arr);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Ignore inactive empty values for standard scalar operators
|
|
206
|
+
if (val === undefined || val === null || val === '') {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
switch (operator) {
|
|
211
|
+
case 'contains':
|
|
212
|
+
for (const t of targets) t.where(column, 'like', `%${val}%`);
|
|
213
|
+
break;
|
|
214
|
+
|
|
215
|
+
case 'startswith':
|
|
216
|
+
for (const t of targets) t.where(column, 'like', `${val}%`);
|
|
217
|
+
break;
|
|
218
|
+
|
|
219
|
+
case 'endswith':
|
|
220
|
+
for (const t of targets) t.where(column, 'like', `%${val}`);
|
|
221
|
+
break;
|
|
222
|
+
|
|
223
|
+
case 'like':
|
|
224
|
+
for (const t of targets) t.where(column, 'like', String(val));
|
|
225
|
+
break;
|
|
226
|
+
|
|
227
|
+
case 'ilike':
|
|
228
|
+
for (const t of targets) t.where(column, 'ilike', String(val));
|
|
229
|
+
break;
|
|
230
|
+
|
|
231
|
+
case 'gt':
|
|
232
|
+
for (const t of targets) t.where(column, '>', val);
|
|
233
|
+
break;
|
|
234
|
+
|
|
235
|
+
case 'gte':
|
|
236
|
+
for (const t of targets) t.where(column, '>=', val);
|
|
237
|
+
break;
|
|
238
|
+
|
|
239
|
+
case 'lt':
|
|
240
|
+
for (const t of targets) t.where(column, '<', val);
|
|
241
|
+
break;
|
|
242
|
+
|
|
243
|
+
case 'lte':
|
|
244
|
+
for (const t of targets) t.where(column, '<=', val);
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
case 'ne':
|
|
248
|
+
for (const t of targets) t.where(column, '!=', val);
|
|
249
|
+
break;
|
|
250
|
+
|
|
251
|
+
case 'eq':
|
|
252
|
+
default:
|
|
253
|
+
for (const t of targets) t.where(column, '=', val);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Builds a query with filters applied (used by Admin Panel and Repositories)
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} repoOrQuery - Repository instance or Knex QueryBuilder
|
|
262
|
+
* @param {Object} filters - Filter object: { column: { op, value, from, to } } or { column: value }
|
|
263
|
+
* @param {Object} [options] - Options
|
|
264
|
+
* @param {boolean} [options.onlyTrashed] - For soft delete: only trashed records
|
|
265
|
+
* @param {Object} [options.model] - Optional ModelDefinition for column verification
|
|
266
|
+
* @returns {Object} Query builder with filters applied
|
|
267
|
+
*/
|
|
268
|
+
function buildFilteredQuery(repoOrQuery, filters, options = {}) {
|
|
269
|
+
let query = (repoOrQuery && typeof repoOrQuery.query === 'function')
|
|
270
|
+
? repoOrQuery.query()
|
|
271
|
+
: repoOrQuery;
|
|
272
|
+
|
|
273
|
+
if (options.onlyTrashed && query && typeof query.onlyTrashed === 'function') {
|
|
274
|
+
query = query.onlyTrashed();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!filters || typeof filters !== 'object' || Object.keys(filters).length === 0) {
|
|
278
|
+
return query;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const model = options.model || (repoOrQuery && repoOrQuery.model);
|
|
282
|
+
const columns = model?.columns;
|
|
283
|
+
|
|
284
|
+
for (const [column, filter] of Object.entries(filters)) {
|
|
285
|
+
if (!filter) continue;
|
|
286
|
+
if (columns && typeof columns.has === 'function' && !columns.has(column)) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const meta = columns && typeof columns.get === 'function' ? columns.get(column) : null;
|
|
291
|
+
|
|
292
|
+
if (typeof filter === 'object' && !Array.isArray(filter)) {
|
|
293
|
+
const rawOp = filter.op || filter.operator;
|
|
294
|
+
const normOp = rawOp ? normalizeOperator(rawOp) : null;
|
|
295
|
+
if (normOp === 'is_null' || normOp === 'is_not_null') {
|
|
296
|
+
applyFilterCondition([query], column, normOp, filter.value !== undefined ? filter.value : true, meta);
|
|
297
|
+
} else if (normOp === 'between') {
|
|
298
|
+
applyFilterCondition([query], column, rawOp, filter, meta);
|
|
299
|
+
} else if (filter.value !== undefined) {
|
|
300
|
+
applyFilterCondition([query], column, rawOp || 'contains', filter.value, meta);
|
|
301
|
+
} else {
|
|
302
|
+
for (const [nestedOp, nestedVal] of Object.entries(filter)) {
|
|
303
|
+
applyFilterCondition([query], column, nestedOp, nestedVal, meta);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
applyFilterCondition([query], column, 'eq', filter, meta);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return query;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Extracts and applies column filters from an HTTP request or filter object to query builders
|
|
316
|
+
* (used by REST Resources and API routes)
|
|
317
|
+
*
|
|
318
|
+
* @param {Object} query - Main QueryBuilder
|
|
319
|
+
* @param {Object} [countQuery] - Optional Count QueryBuilder
|
|
320
|
+
* @param {import('./types').ModelDefinition} model - Model definition
|
|
321
|
+
* @param {import('express').Request|Object} reqOrQuery - Express request or query object
|
|
322
|
+
* @returns {{ query: Object, countQuery: Object }} Mutated query instances
|
|
323
|
+
*/
|
|
324
|
+
function applyColumnFilters(query, countQuery, model, reqOrQuery) {
|
|
325
|
+
const q = query;
|
|
326
|
+
const c = countQuery;
|
|
327
|
+
const targets = [q, c].filter(Boolean);
|
|
328
|
+
|
|
329
|
+
const rawQuery = (reqOrQuery && typeof reqOrQuery === 'object' && 'query' in reqOrQuery)
|
|
330
|
+
? reqOrQuery.query
|
|
331
|
+
: (reqOrQuery || {});
|
|
332
|
+
|
|
333
|
+
if (!rawQuery || typeof rawQuery !== 'object') {
|
|
334
|
+
return { query: q, countQuery: c };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const hasColumns = model && model.columns && typeof model.columns.has === 'function';
|
|
338
|
+
|
|
339
|
+
// 1. Process dedicated filter object: ?filter[col]=... or ?filter[col][op]=... or ?filter={ col: { op, value } }
|
|
340
|
+
if (rawQuery.filter && typeof rawQuery.filter === 'object') {
|
|
341
|
+
for (const [col, colFilter] of Object.entries(rawQuery.filter)) {
|
|
342
|
+
if (hasColumns && !model.columns.has(col)) continue;
|
|
343
|
+
const meta = hasColumns ? model.columns.get(col) : null;
|
|
344
|
+
|
|
345
|
+
if (colFilter && typeof colFilter === 'object' && !Array.isArray(colFilter)) {
|
|
346
|
+
const rawColOp = colFilter.op || colFilter.operator;
|
|
347
|
+
const normColOp = rawColOp ? normalizeOperator(rawColOp) : null;
|
|
348
|
+
if (normColOp === 'is_null' || normColOp === 'is_not_null') {
|
|
349
|
+
applyFilterCondition(targets, col, normColOp, colFilter.value !== undefined ? colFilter.value : true, meta);
|
|
350
|
+
} else if (colFilter.op !== undefined && colFilter.value !== undefined) {
|
|
351
|
+
applyFilterCondition(targets, col, colFilter.op, colFilter.value, meta);
|
|
352
|
+
} else if (colFilter.from !== undefined || colFilter.to !== undefined) {
|
|
353
|
+
applyFilterCondition(targets, col, colFilter.op || 'between', colFilter, meta);
|
|
354
|
+
} else {
|
|
355
|
+
for (const [op, val] of Object.entries(colFilter)) {
|
|
356
|
+
applyFilterCondition(targets, col, op, val, meta);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
} else if (colFilter !== undefined && colFilter !== '') {
|
|
360
|
+
applyFilterCondition(targets, col, 'eq', colFilter, meta);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 2. Process top-level query parameters: ?price[gte]=100 or ?status=active
|
|
366
|
+
for (const [key, value] of Object.entries(rawQuery)) {
|
|
367
|
+
if (RESERVED_QUERY_KEYS.has(key) || key === 'filter') {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (hasColumns && !model.columns.has(key)) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const meta = hasColumns ? model.columns.get(key) : null;
|
|
374
|
+
|
|
375
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
376
|
+
for (const [op, val] of Object.entries(value)) {
|
|
377
|
+
applyFilterCondition(targets, key, op, val, meta);
|
|
378
|
+
}
|
|
379
|
+
} else if (value !== undefined && value !== '') {
|
|
380
|
+
applyFilterCondition(targets, key, 'eq', value, meta);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { query: q, countQuery: c };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
RESERVED_QUERY_KEYS,
|
|
389
|
+
normalizeOperator,
|
|
390
|
+
applyFilterCondition,
|
|
391
|
+
buildFilteredQuery,
|
|
392
|
+
applyColumnFilters,
|
|
393
|
+
};
|
|
@@ -206,7 +206,21 @@ class ShutdownManager {
|
|
|
206
206
|
|
|
207
207
|
this._log('info', `Starting ${modeLabel} shutdown (reason: ${reason})`);
|
|
208
208
|
|
|
209
|
-
// 1.
|
|
209
|
+
// 1. Run plugin disposers in reverse registration order (terminates SSE streams, WebSockets, devReload clients, and workers)
|
|
210
|
+
if (this._disposers.length > 0) {
|
|
211
|
+
this._log('info', `Disposing ${this._disposers.length} plugins`);
|
|
212
|
+
// Reverse array to tear down dependencies in reverse order
|
|
213
|
+
const reverseDisposers = this._disposers.slice().reverse();
|
|
214
|
+
for (const { name, fn } of reverseDisposers) {
|
|
215
|
+
try {
|
|
216
|
+
await fn();
|
|
217
|
+
} catch (err) {
|
|
218
|
+
this._log('error', `Error in plugin disposer "${name}":`, err);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 2. Close HTTP server adapters (gracefully drain in-flight HTTP requests)
|
|
210
224
|
if (this._adapters.length > 0) {
|
|
211
225
|
this._log('info', 'Closing HTTP server');
|
|
212
226
|
|
|
@@ -270,7 +284,7 @@ class ShutdownManager {
|
|
|
270
284
|
}
|
|
271
285
|
}
|
|
272
286
|
|
|
273
|
-
//
|
|
287
|
+
// 3. Run application shutdown hooks (app.onShutdown)
|
|
274
288
|
if (this._hooks.length > 0) {
|
|
275
289
|
this._log('info', `Running ${this._hooks.length} shutdown hooks`);
|
|
276
290
|
for (const hook of this._hooks) {
|
|
@@ -282,20 +296,6 @@ class ShutdownManager {
|
|
|
282
296
|
}
|
|
283
297
|
}
|
|
284
298
|
|
|
285
|
-
// 3. Run plugin disposers in reverse registration order
|
|
286
|
-
if (this._disposers.length > 0) {
|
|
287
|
-
this._log('info', `Disposing ${this._disposers.length} plugins`);
|
|
288
|
-
// Reverse array to tear down dependencies in reverse order
|
|
289
|
-
const reverseDisposers = this._disposers.slice().reverse();
|
|
290
|
-
for (const { name, fn } of reverseDisposers) {
|
|
291
|
-
try {
|
|
292
|
-
await fn();
|
|
293
|
-
} catch (err) {
|
|
294
|
-
this._log('error', `Error in plugin disposer "${name}":`, err);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
299
|
// 4. Detach signal hooks from process
|
|
300
300
|
this.disableShutdownHooks();
|
|
301
301
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Zero-Dependency Crypto & Security Utilities
|
|
3
|
+
*
|
|
4
|
+
* Lightweight, timing-attack resistant cryptographic helpers.
|
|
5
|
+
*
|
|
6
|
+
* @module core/utils/crypto
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Constant-time equality comparison to prevent timing attacks.
|
|
15
|
+
*
|
|
16
|
+
* Compares two strings or Buffers in constant time using `crypto.timingSafeEqual`.
|
|
17
|
+
* Handles length mismatches by executing a dummy comparison against identical buffer
|
|
18
|
+
* to protect against timing leakage while safely returning `false`.
|
|
19
|
+
*
|
|
20
|
+
* @param {string|Buffer} a - First value to compare
|
|
21
|
+
* @param {string|Buffer} b - Second value to compare
|
|
22
|
+
* @returns {boolean} True if values are identical, false otherwise
|
|
23
|
+
*/
|
|
24
|
+
function safeCompare(a, b) {
|
|
25
|
+
if (a === null || a === undefined || b === null || b === undefined) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isABuf = Buffer.isBuffer(a);
|
|
30
|
+
const isBBuf = Buffer.isBuffer(b);
|
|
31
|
+
|
|
32
|
+
if (isABuf && isBBuf) {
|
|
33
|
+
if (a.length !== b.length) {
|
|
34
|
+
if (a.length > 0) {
|
|
35
|
+
try {
|
|
36
|
+
crypto.timingSafeEqual(a, a);
|
|
37
|
+
} catch (_) {}
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return crypto.timingSafeEqual(a, b);
|
|
43
|
+
} catch (_) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (typeof a !== 'string' || typeof b !== 'string') {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
53
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
54
|
+
|
|
55
|
+
if (bufA.length !== bufB.length) {
|
|
56
|
+
try {
|
|
57
|
+
// Dummy comparison to prevent timing leaks on length mismatches
|
|
58
|
+
crypto.timingSafeEqual(bufA, bufA);
|
|
59
|
+
} catch (_) {}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
65
|
+
} catch (_) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
safeCompare,
|
|
72
|
+
timingSafeEqual: safeCompare,
|
|
73
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Zero-Dependency Fetch Utility
|
|
3
|
+
*
|
|
4
|
+
* Lightweight, production-grade wrapper around native `fetch` with built-in:
|
|
5
|
+
* - Configurable timeout with automatic AbortController
|
|
6
|
+
* - Seamless signal composition (external AbortSignal + timeout AbortSignal)
|
|
7
|
+
* - Timer unreferencing (`timer.unref()`) to prevent hanging Node.js event loop
|
|
8
|
+
* - Semantic error wrapping (ETIMEDOUT / TimeoutError)
|
|
9
|
+
* - JSON convenience helper (`fetchWithTimeout.json`)
|
|
10
|
+
*
|
|
11
|
+
* @module core/utils/fetch
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Executes a fetch request with timeout and abort controller support.
|
|
18
|
+
*
|
|
19
|
+
* @param {string | URL | Request} url - Target URL or Request instance
|
|
20
|
+
* @param {RequestInit & { timeout?: number }} [options] - Standard RequestInit plus optional timeout (ms)
|
|
21
|
+
* @returns {Promise<Response>}
|
|
22
|
+
*/
|
|
23
|
+
async function fetchWithTimeout(url, options = {}) {
|
|
24
|
+
const { timeout = 10000, signal, ...fetchOptions } = options;
|
|
25
|
+
|
|
26
|
+
if (signal && signal.aborted) {
|
|
27
|
+
const reason = (signal.reason !== undefined && signal.reason !== null)
|
|
28
|
+
? signal.reason
|
|
29
|
+
: new Error('This operation was aborted');
|
|
30
|
+
throw reason;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (typeof AbortController === 'undefined') {
|
|
34
|
+
return fetch(url, { ...fetchOptions, signal });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
let timer = null;
|
|
39
|
+
let callerSignalListener = null;
|
|
40
|
+
|
|
41
|
+
if (typeof timeout === 'number' && timeout > 0) {
|
|
42
|
+
timer = setTimeout(() => {
|
|
43
|
+
const err = new Error(`Request timed out after ${timeout}ms`);
|
|
44
|
+
err.name = 'TimeoutError';
|
|
45
|
+
err.code = 'ETIMEDOUT';
|
|
46
|
+
controller.abort(err);
|
|
47
|
+
}, timeout);
|
|
48
|
+
if (timer && typeof timer.unref === 'function') {
|
|
49
|
+
timer.unref();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (signal) {
|
|
54
|
+
callerSignalListener = () => {
|
|
55
|
+
controller.abort(signal.reason);
|
|
56
|
+
};
|
|
57
|
+
signal.addEventListener('abort', callerSignalListener, { once: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
return await fetch(url, {
|
|
62
|
+
...fetchOptions,
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (controller.signal.aborted && controller.signal.reason) {
|
|
67
|
+
const reason = controller.signal.reason;
|
|
68
|
+
if (reason instanceof Error && (reason.name === 'TimeoutError' || reason.code === 'ETIMEDOUT')) {
|
|
69
|
+
throw reason;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (err && err.name === 'AbortError' && controller.signal.aborted && controller.signal.reason) {
|
|
73
|
+
throw controller.signal.reason;
|
|
74
|
+
}
|
|
75
|
+
throw err;
|
|
76
|
+
} finally {
|
|
77
|
+
if (timer) {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
}
|
|
80
|
+
if (signal && callerSignalListener) {
|
|
81
|
+
signal.removeEventListener('abort', callerSignalListener);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fetch with timeout and automatically parse JSON response.
|
|
88
|
+
*
|
|
89
|
+
* @param {string | URL | Request} url
|
|
90
|
+
* @param {RequestInit & { timeout?: number }} [options]
|
|
91
|
+
* @returns {Promise<{ res: Response, data: any }>}
|
|
92
|
+
*/
|
|
93
|
+
fetchWithTimeout.json = async function fetchJson(url, options = {}) {
|
|
94
|
+
const res = await fetchWithTimeout(url, {
|
|
95
|
+
...options,
|
|
96
|
+
headers: {
|
|
97
|
+
Accept: 'application/json',
|
|
98
|
+
...(options.headers || {}),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
let data = null;
|
|
102
|
+
const contentType = (res.headers && typeof res.headers.get === 'function')
|
|
103
|
+
? (res.headers.get('content-type') || '')
|
|
104
|
+
: '';
|
|
105
|
+
if (contentType.includes('application/json')) {
|
|
106
|
+
data = await res.json();
|
|
107
|
+
} else {
|
|
108
|
+
const text = await res.text();
|
|
109
|
+
try {
|
|
110
|
+
data = JSON.parse(text);
|
|
111
|
+
} catch {
|
|
112
|
+
data = { raw: text };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { res, data };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
fetchWithTimeout,
|
|
120
|
+
};
|