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
package/core/orm/scopes.js
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
const { generateNanoid } = require('./utils/nanoid');
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Determine the soft delete column name for a model
|
|
11
|
+
* @param {import('./types').ModelDefinition} model - Model definition
|
|
12
|
+
* @returns {string} Column name ('deletedAt' or 'deleted_at')
|
|
13
|
+
*/
|
|
14
|
+
function getSoftDeleteColumn(model) {
|
|
15
|
+
return (model && model.columns && model.columns.has('deletedAt')) ? 'deletedAt' : 'deleted_at';
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
/**
|
|
10
19
|
* Apply soft delete scope to a query builder
|
|
11
20
|
* @param {import('knex').Knex.QueryBuilder} qb - Knex query builder
|
|
@@ -18,9 +27,11 @@ function applySoftDeleteScope(qb, context, model) {
|
|
|
18
27
|
return qb;
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
const deletedCol = getSoftDeleteColumn(model);
|
|
31
|
+
|
|
21
32
|
// If onlyTrashed is set, only return deleted records
|
|
22
33
|
if (context.onlyTrashed) {
|
|
23
|
-
return qb.whereNotNull(
|
|
34
|
+
return qb.whereNotNull(deletedCol);
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
// If withTrashed is set, return all records (no filter)
|
|
@@ -29,7 +40,7 @@ function applySoftDeleteScope(qb, context, model) {
|
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
// Default: only return non-deleted records
|
|
32
|
-
return qb.whereNull(
|
|
43
|
+
return qb.whereNull(deletedCol);
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
/**
|
|
@@ -74,10 +85,13 @@ function applyInsertTimestamps(data, model) {
|
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
const now = new Date();
|
|
88
|
+
const createdCol = (model.columns && model.columns.has('createdAt')) ? 'createdAt' : 'created_at';
|
|
89
|
+
const updatedCol = (model.columns && model.columns.has('updatedAt')) ? 'updatedAt' : 'updated_at';
|
|
90
|
+
|
|
77
91
|
return {
|
|
78
92
|
...data,
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
[createdCol]: data[createdCol] !== undefined ? data[createdCol] : (data.createdAt || data.created_at || now),
|
|
94
|
+
[updatedCol]: data[updatedCol] !== undefined ? data[updatedCol] : (data.updatedAt || data.updated_at || now),
|
|
81
95
|
};
|
|
82
96
|
}
|
|
83
97
|
|
|
@@ -92,9 +106,11 @@ function applyUpdateTimestamps(data, model) {
|
|
|
92
106
|
return data;
|
|
93
107
|
}
|
|
94
108
|
|
|
109
|
+
const updatedCol = (model.columns && model.columns.has('updatedAt')) ? 'updatedAt' : 'updated_at';
|
|
110
|
+
|
|
95
111
|
return {
|
|
96
112
|
...data,
|
|
97
|
-
|
|
113
|
+
[updatedCol]: new Date(),
|
|
98
114
|
};
|
|
99
115
|
}
|
|
100
116
|
|
|
@@ -143,18 +159,32 @@ function applyInsertNanoidPrimary(data, model) {
|
|
|
143
159
|
/**
|
|
144
160
|
* Get soft delete data (for UPDATE instead of DELETE)
|
|
145
161
|
* @param {Date|string|number} [date=new Date()] - Soft delete timestamp
|
|
162
|
+
* @param {string|import('./types').ModelDefinition} [colOrModel='deleted_at'] - Column name or model definition
|
|
146
163
|
* @returns {Object} Soft delete update data
|
|
147
164
|
*/
|
|
148
|
-
function getSoftDeleteData(date = new Date()) {
|
|
149
|
-
|
|
165
|
+
function getSoftDeleteData(date = new Date(), colOrModel = 'deleted_at') {
|
|
166
|
+
let columnName = 'deleted_at';
|
|
167
|
+
if (typeof colOrModel === 'string') {
|
|
168
|
+
columnName = colOrModel;
|
|
169
|
+
} else if (colOrModel && typeof colOrModel === 'object') {
|
|
170
|
+
columnName = getSoftDeleteColumn(colOrModel);
|
|
171
|
+
}
|
|
172
|
+
return { [columnName]: date instanceof Date ? date : new Date(date) };
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
/**
|
|
153
176
|
* Get restore data (to undo soft delete)
|
|
177
|
+
* @param {string|import('./types').ModelDefinition} [colOrModel='deleted_at'] - Column name or model definition
|
|
154
178
|
* @returns {Object} Restore update data
|
|
155
179
|
*/
|
|
156
|
-
function getRestoreData() {
|
|
157
|
-
|
|
180
|
+
function getRestoreData(colOrModel = 'deleted_at') {
|
|
181
|
+
let columnName = 'deleted_at';
|
|
182
|
+
if (typeof colOrModel === 'string') {
|
|
183
|
+
columnName = colOrModel;
|
|
184
|
+
} else if (colOrModel && typeof colOrModel === 'object') {
|
|
185
|
+
columnName = getSoftDeleteColumn(colOrModel);
|
|
186
|
+
}
|
|
187
|
+
return { [columnName]: null };
|
|
158
188
|
}
|
|
159
189
|
|
|
160
190
|
/**
|
|
@@ -207,6 +237,7 @@ module.exports = {
|
|
|
207
237
|
applyUpdateModifiers,
|
|
208
238
|
getSoftDeleteData,
|
|
209
239
|
getRestoreData,
|
|
240
|
+
getSoftDeleteColumn,
|
|
210
241
|
createScopeContext,
|
|
211
242
|
};
|
|
212
243
|
|
package/core/orm/types.js
CHANGED
|
@@ -150,12 +150,23 @@
|
|
|
150
150
|
// Model Types
|
|
151
151
|
// ============================================================================
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* @typedef {Object} ModelConfigOptions
|
|
155
|
+
* @property {boolean|{createdAt?: string, updatedAt?: string, snakeCase?: boolean}} [timestamps=false] - Auto timestamps
|
|
156
|
+
* @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDelete=false] - Soft delete support
|
|
157
|
+
* @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDeletes=false] - Soft delete support alias
|
|
158
|
+
*/
|
|
159
|
+
|
|
153
160
|
/**
|
|
154
161
|
* @typedef {Object} ModelOptions
|
|
155
162
|
* @property {string} name - Model name (e.g., 'User')
|
|
156
163
|
* @property {string} table - Database table name (e.g., 'users')
|
|
157
|
-
* @property {import('zod').ZodObject} schema - Zod schema
|
|
164
|
+
* @property {import('zod').ZodObject|Record<string, any>} schema - Zod schema or plain object schema shape
|
|
158
165
|
* @property {string} [primaryKey='id'] - Primary key column name
|
|
166
|
+
* @property {boolean|{createdAt?: string, updatedAt?: string, snakeCase?: boolean}} [timestamps] - Auto timestamps (injects createdAt/updatedAt)
|
|
167
|
+
* @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDelete] - Soft delete support (injects deletedAt)
|
|
168
|
+
* @property {boolean|{deletedAt?: string, snakeCase?: boolean}} [softDeletes] - Soft delete support alias
|
|
169
|
+
* @property {ModelConfigOptions} [options] - Additional model options (e.g. timestamps, softDelete)
|
|
159
170
|
* @property {RelationsMap} [relations={}] - Relation definitions
|
|
160
171
|
* @property {ScopeOptions} [scopes={}] - Scope options
|
|
161
172
|
* @property {AdminMetadata} [admin] - Admin panel metadata
|
package/core/queue/manager.js
CHANGED
|
@@ -527,6 +527,11 @@ class QueueManager extends EventEmitter {
|
|
|
527
527
|
}
|
|
528
528
|
} catch (err) {
|
|
529
529
|
this.emit('error', err);
|
|
530
|
+
// Back off polling interval on database or adapter error to avoid tight spin-loop
|
|
531
|
+
this._currentPollInterval = Math.min(
|
|
532
|
+
this.maxPollInterval,
|
|
533
|
+
Math.round(this._currentPollInterval * 2)
|
|
534
|
+
);
|
|
530
535
|
this._scheduleNextTick(this._currentPollInterval);
|
|
531
536
|
return;
|
|
532
537
|
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Zero-Dependency HTML Utilities
|
|
3
|
+
*
|
|
4
|
+
* High-performance, XSS-safe HTML entity escaping and unescaping.
|
|
5
|
+
*
|
|
6
|
+
* @module core/utils/html
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const HTML_ESCAPE_TEST_RE = /[&<>"']/;
|
|
12
|
+
const HTML_ESCAPE_RE = /[&<>"']/g;
|
|
13
|
+
|
|
14
|
+
const HTML_ESCAPES = {
|
|
15
|
+
'&': '&',
|
|
16
|
+
'<': '<',
|
|
17
|
+
'>': '>',
|
|
18
|
+
'"': '"',
|
|
19
|
+
"'": ''',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const HTML_UNESCAPE_RE = /&(?:amp|lt|gt|quot|#39|#039|#x27);/g;
|
|
23
|
+
|
|
24
|
+
const HTML_UNESCAPES = {
|
|
25
|
+
'&': '&',
|
|
26
|
+
'<': '<',
|
|
27
|
+
'>': '>',
|
|
28
|
+
'"': '"',
|
|
29
|
+
''': "'",
|
|
30
|
+
''': "'",
|
|
31
|
+
''': "'",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Escapes characters with special meaning in HTML to prevent XSS injection attacks.
|
|
36
|
+
* Fast-paths strings without special characters to avoid unnecessary allocations.
|
|
37
|
+
*
|
|
38
|
+
* @param {unknown} value - Value to escape (converted to string; null/undefined becomes '')
|
|
39
|
+
* @returns {string} HTML-escaped string
|
|
40
|
+
*/
|
|
41
|
+
function escapeHtml(value) {
|
|
42
|
+
if (value === null || value === undefined) {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const str = typeof value === 'string' ? value : String(value);
|
|
47
|
+
if (!HTML_ESCAPE_TEST_RE.test(str)) {
|
|
48
|
+
return str;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return str.replace(HTML_ESCAPE_RE, (char) => HTML_ESCAPES[char]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Unescapes common HTML entities back to their literal character representations.
|
|
56
|
+
*
|
|
57
|
+
* @param {unknown} value - HTML string to unescape
|
|
58
|
+
* @returns {string} Unescaped string
|
|
59
|
+
*/
|
|
60
|
+
function unescapeHtml(value) {
|
|
61
|
+
if (value === null || value === undefined) {
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const str = typeof value === 'string' ? value : String(value);
|
|
66
|
+
return str.replace(HTML_UNESCAPE_RE, (match) => HTML_UNESCAPES[match] || match);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
escapeHtml,
|
|
71
|
+
unescapeHtml,
|
|
72
|
+
};
|
package/core/utils/ip.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Universal Client IP Extraction Utility
|
|
5
|
+
* Safely extracts client IP address with proxy support, header normalizations,
|
|
6
|
+
* and customizable fallbacks.
|
|
7
|
+
*
|
|
8
|
+
* Precedence:
|
|
9
|
+
* 1. x-forwarded-for header (first IP if comma-separated or array)
|
|
10
|
+
* 2. x-real-ip header
|
|
11
|
+
* 3. req.ip (Express trust proxy / custom assignment)
|
|
12
|
+
* 4. req.connection.remoteAddress
|
|
13
|
+
* 5. req.socket.remoteAddress
|
|
14
|
+
* 6. req.info.remoteAddress (Fastify/Hapi compatibility)
|
|
15
|
+
* 7. fallback (defaults to '127.0.0.1' if not explicitly provided)
|
|
16
|
+
*
|
|
17
|
+
* @param {Object} [req] - Express / Node.js HTTP request object
|
|
18
|
+
* @param {any} [fallback='127.0.0.1'] - Fallback value if no IP could be extracted
|
|
19
|
+
* @returns {any}
|
|
20
|
+
*/
|
|
21
|
+
function getClientIp(req, fallback) {
|
|
22
|
+
const defaultFallback = arguments.length > 1 ? fallback : '127.0.0.1';
|
|
23
|
+
if (!req || typeof req !== 'object') {
|
|
24
|
+
return defaultFallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const headers = req.headers;
|
|
28
|
+
|
|
29
|
+
// 1. x-forwarded-for (can be string or array in Node HTTP/proxies)
|
|
30
|
+
let forwarded = headers ? (headers['x-forwarded-for'] || headers['X-Forwarded-For']) : undefined;
|
|
31
|
+
if (!forwarded && typeof req.get === 'function') {
|
|
32
|
+
forwarded = req.get('x-forwarded-for');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (forwarded) {
|
|
36
|
+
const ips = typeof forwarded === 'string' ? forwarded.split(',') : forwarded;
|
|
37
|
+
const first = Array.isArray(ips) ? ips[0] : ips;
|
|
38
|
+
if (first && typeof first === 'string') {
|
|
39
|
+
const trimmed = first.trim();
|
|
40
|
+
if (trimmed) return trimmed;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. x-real-ip
|
|
45
|
+
let realIp = headers ? (headers['x-real-ip'] || headers['X-Real-IP']) : undefined;
|
|
46
|
+
if (!realIp && typeof req.get === 'function') {
|
|
47
|
+
realIp = req.get('x-real-ip');
|
|
48
|
+
}
|
|
49
|
+
if (realIp && typeof realIp === 'string') {
|
|
50
|
+
const trimmed = realIp.trim();
|
|
51
|
+
if (trimmed) return trimmed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. req.ip
|
|
55
|
+
if (req.ip && typeof req.ip === 'string') {
|
|
56
|
+
const trimmed = req.ip.trim();
|
|
57
|
+
if (trimmed) return trimmed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 4. req.connection.remoteAddress
|
|
61
|
+
const connAddr = req.connection && req.connection.remoteAddress;
|
|
62
|
+
if (connAddr && typeof connAddr === 'string') {
|
|
63
|
+
const trimmed = connAddr.trim();
|
|
64
|
+
if (trimmed) return trimmed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 5. req.socket.remoteAddress
|
|
68
|
+
const sockAddr = req.socket && req.socket.remoteAddress;
|
|
69
|
+
if (sockAddr && typeof sockAddr === 'string') {
|
|
70
|
+
const trimmed = sockAddr.trim();
|
|
71
|
+
if (trimmed) return trimmed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 6. req.info.remoteAddress (Fastify/Hapi compatibility)
|
|
75
|
+
const infoAddr = req.info && req.info.remoteAddress;
|
|
76
|
+
if (infoAddr && typeof infoAddr === 'string') {
|
|
77
|
+
const trimmed = infoAddr.trim();
|
|
78
|
+
if (trimmed) return trimmed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return defaultFallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
getClientIp,
|
|
86
|
+
};
|
package/core/validation/index.js
CHANGED
|
@@ -22,13 +22,19 @@ function hasDangerousKeys(obj) {
|
|
|
22
22
|
return false;
|
|
23
23
|
}
|
|
24
24
|
if (Array.isArray(obj)) {
|
|
25
|
-
|
|
25
|
+
const len = obj.length;
|
|
26
|
+
for (let i = 0; i < len; i++) {
|
|
27
|
+
if (hasDangerousKeys(obj[i])) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
26
30
|
}
|
|
27
|
-
for (const key
|
|
31
|
+
for (const key in obj) {
|
|
32
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
28
33
|
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
29
34
|
return true;
|
|
30
35
|
}
|
|
31
|
-
|
|
36
|
+
const val = obj[key];
|
|
37
|
+
if (val !== null && typeof val === 'object' && hasDangerousKeys(val)) {
|
|
32
38
|
return true;
|
|
33
39
|
}
|
|
34
40
|
}
|
package/index.d.ts
CHANGED
|
@@ -830,6 +830,16 @@ export function loadListeners(dirPath: string, dispatcher: EventDispatcher, opti
|
|
|
830
830
|
|
|
831
831
|
export function createHelpers(context: Record<string, unknown>): Record<string, unknown>;
|
|
832
832
|
|
|
833
|
+
export interface FetchWithTimeoutOptions extends RequestInit {
|
|
834
|
+
timeout?: number;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export function fetchWithTimeout(url: string | URL | Request, options?: FetchWithTimeoutOptions): Promise<Response>;
|
|
838
|
+
export function safeCompare(a: string | Buffer | null | undefined, b: string | Buffer | null | undefined): boolean;
|
|
839
|
+
export function escapeHtml(value: unknown): string;
|
|
840
|
+
export function unescapeHtml(value: unknown): string;
|
|
841
|
+
export function getClientIp<T = string>(req?: any, fallback?: T): string | T;
|
|
842
|
+
|
|
833
843
|
export const utils: Record<string, unknown>;
|
|
834
844
|
|
|
835
845
|
export class AssetManager {
|
|
@@ -1072,11 +1082,20 @@ export type InferModel<T> = T extends ModelDefinition<infer TModel>
|
|
|
1072
1082
|
export type InferModelCreate<T> = Partial<InferModel<T>> & Record<string, any>;
|
|
1073
1083
|
export type InferModelUpdate<T> = Partial<InferModel<T>>;
|
|
1074
1084
|
|
|
1075
|
-
export interface ModelOptions<TSchema
|
|
1085
|
+
export interface ModelOptions<TSchema = ZodObject<Record<string, ZodTypeAny>>> {
|
|
1076
1086
|
name: string;
|
|
1077
1087
|
table: string;
|
|
1078
|
-
schema: TSchema
|
|
1088
|
+
schema: TSchema | Record<string, any>;
|
|
1079
1089
|
primaryKey?: string;
|
|
1090
|
+
timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
|
|
1091
|
+
softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
|
|
1092
|
+
softDeletes?: boolean | { deletedAt?: string; snakeCase?: boolean };
|
|
1093
|
+
options?: {
|
|
1094
|
+
timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
|
|
1095
|
+
softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
|
|
1096
|
+
softDeletes?: boolean | { deletedAt?: string; snakeCase?: boolean };
|
|
1097
|
+
[key: string]: unknown;
|
|
1098
|
+
};
|
|
1080
1099
|
relations?: Record<string, RelationDefinition>;
|
|
1081
1100
|
scopes?: ScopeOptions;
|
|
1082
1101
|
admin?: AdminMetadata;
|
|
@@ -1104,6 +1123,11 @@ export interface ModelDefinition<TModel = Record<string, any>> {
|
|
|
1104
1123
|
timestamps: boolean;
|
|
1105
1124
|
tenant: string | null;
|
|
1106
1125
|
};
|
|
1126
|
+
options?: {
|
|
1127
|
+
timestamps?: boolean | { createdAt?: string; updatedAt?: string; snakeCase?: boolean };
|
|
1128
|
+
softDelete?: boolean | { deletedAt?: string; snakeCase?: boolean };
|
|
1129
|
+
[key: string]: unknown;
|
|
1130
|
+
};
|
|
1107
1131
|
columns: Map<string, unknown>;
|
|
1108
1132
|
admin: {
|
|
1109
1133
|
enabled: boolean;
|
|
@@ -1129,9 +1153,9 @@ export interface ModelDefinition<TModel = Record<string, any>> {
|
|
|
1129
1153
|
cache?: boolean | 'auto' | 'smart' | { strategy: 'auto' | 'smart' };
|
|
1130
1154
|
}
|
|
1131
1155
|
|
|
1132
|
-
export function defineModel<TSchema
|
|
1156
|
+
export function defineModel<TSchema = ZodObject<any>>(
|
|
1133
1157
|
options: ModelOptions<TSchema>
|
|
1134
|
-
): ModelDefinition<
|
|
1158
|
+
): ModelDefinition<any>;
|
|
1135
1159
|
|
|
1136
1160
|
export function getModel<T = Record<string, any>>(name: string): ModelDefinition<T> | undefined;
|
|
1137
1161
|
|
|
@@ -1147,7 +1171,10 @@ export function isCascadeEnabled(relation: RelationDefinition, operation: 'delet
|
|
|
1147
1171
|
|
|
1148
1172
|
/** zdb column helpers (id, string, timestamp, …) + `schema()` */
|
|
1149
1173
|
export type Zdb = Record<string, any> & {
|
|
1150
|
-
schema(shape: Record<string,
|
|
1174
|
+
schema(shape: Record<string, any>): ZodObject<Record<string, ZodTypeAny>>;
|
|
1175
|
+
timestamps(options?: { snakeCase?: boolean; createdAt?: string; updatedAt?: string }): Record<string, any>;
|
|
1176
|
+
softDelete(options?: Record<string, any>): any;
|
|
1177
|
+
softDeletes(options?: { snakeCase?: boolean; deletedAt?: string }): Record<string, any>;
|
|
1151
1178
|
};
|
|
1152
1179
|
|
|
1153
1180
|
export const zdb: Zdb;
|
|
@@ -3770,7 +3797,7 @@ export class SessionManager {
|
|
|
3770
3797
|
}
|
|
3771
3798
|
|
|
3772
3799
|
export function parseUserAgent(userAgent?: string): DeviceInfo;
|
|
3773
|
-
export function getClientIp(req?: any): string;
|
|
3800
|
+
export function getClientIp<T = string>(req?: any, fallback?: T): string | T;
|
|
3774
3801
|
export function createUserSessionsTable(knex: any): Promise<void>;
|
|
3775
3802
|
export function dropUserSessionsTable(knex: any): Promise<void>;
|
|
3776
3803
|
export function createKnexSessionTracker(db: any, options?: { tableName?: string }): SessionTracker;
|