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
package/bin/commands/dev.js
CHANGED
|
@@ -13,8 +13,9 @@ const { spawn } = require('child_process');
|
|
|
13
13
|
function buildWatchArgs() {
|
|
14
14
|
const args = ['--watch'];
|
|
15
15
|
|
|
16
|
-
// Add watch paths for
|
|
17
|
-
|
|
16
|
+
// Add watch paths for server-side code directories (services, models, config, pages)
|
|
17
|
+
// Note: views contains Nunjucks templates which are live-reloaded in-memory by devReloadHub
|
|
18
|
+
const watchPaths = ['pages', 'models', 'services', 'config'];
|
|
18
19
|
|
|
19
20
|
for (const dir of watchPaths) {
|
|
20
21
|
if (fs.existsSync(dir)) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const { escapeHtml } = require('../../core/utils/html');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @param {object} snapshot - { generatedAt, models }
|
|
@@ -661,14 +662,7 @@ function buildOrmMapHtml(snapshot, mermaidSource, meta = {}) {
|
|
|
661
662
|
`;
|
|
662
663
|
}
|
|
663
664
|
|
|
664
|
-
|
|
665
|
-
if (s == null) return '';
|
|
666
|
-
return String(s)
|
|
667
|
-
.replace(/&/g, '&')
|
|
668
|
-
.replace(/</g, '<')
|
|
669
|
-
.replace(/>/g, '>')
|
|
670
|
-
.replace(/"/g, '"');
|
|
671
|
-
}
|
|
665
|
+
|
|
672
666
|
|
|
673
667
|
/**
|
|
674
668
|
* Optional: read package name from cwd/package.json
|
package/core/auth/jwt.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const crypto = require('crypto');
|
|
8
|
+
const { safeCompare } = require('../utils/crypto');
|
|
8
9
|
|
|
9
10
|
const HS256_HEADER_BASE64URL = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
|
|
10
11
|
|
|
@@ -172,10 +173,7 @@ function verifyJwt(token, secret, options = {}) {
|
|
|
172
173
|
|
|
173
174
|
const actualSignatureBuf = Buffer.from(encodedSignature, 'base64url');
|
|
174
175
|
|
|
175
|
-
if (
|
|
176
|
-
expectedSignatureBuf.length !== actualSignatureBuf.length ||
|
|
177
|
-
!crypto.timingSafeEqual(expectedSignatureBuf, actualSignatureBuf)
|
|
178
|
-
) {
|
|
176
|
+
if (!safeCompare(expectedSignatureBuf, actualSignatureBuf)) {
|
|
179
177
|
throw new Error('Invalid JWT signature');
|
|
180
178
|
}
|
|
181
179
|
|
package/core/auth/oauth.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const crypto = require('crypto');
|
|
9
|
+
const { fetchWithTimeout } = require('../utils/fetch');
|
|
10
|
+
const { safeCompare } = require('../utils/crypto');
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Custom error class for OAuth operations
|
|
@@ -57,14 +59,17 @@ const DEFAULT_PROVIDERS = {
|
|
|
57
59
|
userEmailsUrl: 'https://api.github.com/user/emails',
|
|
58
60
|
defaultScopes: ['read:user', 'user:email'],
|
|
59
61
|
defaultParams: {},
|
|
60
|
-
async fetchExtra(tokens) {
|
|
62
|
+
async fetchExtra(tokens, options = {}) {
|
|
61
63
|
try {
|
|
62
|
-
const
|
|
64
|
+
const timeout = typeof options?.timeout === 'number' ? options.timeout : 10000;
|
|
65
|
+
const res = await fetchWithTimeout('https://api.github.com/user/emails', {
|
|
63
66
|
headers: {
|
|
64
67
|
Authorization: `Bearer ${tokens.access_token}`,
|
|
65
68
|
Accept: 'application/vnd.github.v3+json',
|
|
66
69
|
'User-Agent': 'Webspresso-OAuth',
|
|
67
70
|
},
|
|
71
|
+
timeout,
|
|
72
|
+
signal: options?.signal,
|
|
68
73
|
});
|
|
69
74
|
if (res.ok) {
|
|
70
75
|
const emails = await res.json();
|
|
@@ -78,7 +83,7 @@ const DEFAULT_PROVIDERS = {
|
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
} catch {
|
|
81
|
-
// Fallback gracefully if email endpoint is unreachable
|
|
86
|
+
// Fallback gracefully if email endpoint is unreachable or times out
|
|
82
87
|
}
|
|
83
88
|
return null;
|
|
84
89
|
},
|
|
@@ -167,10 +172,7 @@ function verifySignedState(stateString, secret, maxAgeMs = 10 * 60 * 1000) {
|
|
|
167
172
|
.update(body)
|
|
168
173
|
.digest('base64url');
|
|
169
174
|
|
|
170
|
-
|
|
171
|
-
const bufB = Buffer.from(expectedSignature);
|
|
172
|
-
|
|
173
|
-
if (bufA.length !== bufB.length || !crypto.timingSafeEqual(bufA, bufB)) {
|
|
175
|
+
if (!safeCompare(signature, expectedSignature)) {
|
|
174
176
|
throw new OAuthError('OAuth state signature verification failed', 'INVALID_STATE');
|
|
175
177
|
}
|
|
176
178
|
|
|
@@ -202,6 +204,9 @@ class OAuthClient {
|
|
|
202
204
|
constructor(config = {}) {
|
|
203
205
|
this.secret = config.secret || process.env.OAUTH_SECRET || process.env.SESSION_SECRET || 'webspresso-oauth-secret';
|
|
204
206
|
this.stateTtlMs = config.stateTtlMs || 10 * 60 * 1000;
|
|
207
|
+
this.timeout = typeof (config.timeout || config.requestTimeout) === 'number'
|
|
208
|
+
? (config.timeout || config.requestTimeout)
|
|
209
|
+
: 10000;
|
|
205
210
|
this.providers = {};
|
|
206
211
|
|
|
207
212
|
if (config.providers) {
|
|
@@ -227,6 +232,7 @@ class OAuthClient {
|
|
|
227
232
|
redirectUri: options.redirectUri || options.redirect_uri || builtin.redirectUri,
|
|
228
233
|
scopes: options.scopes || options.scope || builtin.defaultScopes || [],
|
|
229
234
|
params: { ...(builtin.defaultParams || {}), ...(options.params || {}) },
|
|
235
|
+
timeout: typeof options.timeout === 'number' ? options.timeout : (builtin.timeout || this.timeout || 10000),
|
|
230
236
|
};
|
|
231
237
|
return this;
|
|
232
238
|
}
|
|
@@ -330,7 +336,7 @@ class OAuthClient {
|
|
|
330
336
|
* @param {string} [params.codeVerifier]
|
|
331
337
|
* @returns {Promise<Object>} Token response
|
|
332
338
|
*/
|
|
333
|
-
async exchangeCode(providerName, { code, redirectUri, codeVerifier }) {
|
|
339
|
+
async exchangeCode(providerName, { code, redirectUri, codeVerifier, timeout, signal } = {}) {
|
|
334
340
|
const provider = this.getProvider(providerName);
|
|
335
341
|
|
|
336
342
|
if (!code) {
|
|
@@ -354,9 +360,13 @@ class OAuthClient {
|
|
|
354
360
|
bodyParams.set('code_verifier', codeVerifier);
|
|
355
361
|
}
|
|
356
362
|
|
|
363
|
+
const requestTimeout = typeof timeout === 'number'
|
|
364
|
+
? timeout
|
|
365
|
+
: (provider.timeout || this.timeout || 10000);
|
|
366
|
+
|
|
357
367
|
let res;
|
|
358
368
|
try {
|
|
359
|
-
res = await
|
|
369
|
+
res = await fetchWithTimeout(provider.tokenUrl, {
|
|
360
370
|
method: 'POST',
|
|
361
371
|
headers: {
|
|
362
372
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -364,6 +374,8 @@ class OAuthClient {
|
|
|
364
374
|
'User-Agent': 'Webspresso-OAuth',
|
|
365
375
|
},
|
|
366
376
|
body: bodyParams.toString(),
|
|
377
|
+
timeout: requestTimeout,
|
|
378
|
+
signal,
|
|
367
379
|
});
|
|
368
380
|
} catch (err) {
|
|
369
381
|
throw new OAuthError(`Token exchange network request failed: ${err.message}`, 'NETWORK_ERROR', err);
|
|
@@ -398,7 +410,7 @@ class OAuthClient {
|
|
|
398
410
|
* @param {Object} tokens - Tokens object returned from exchangeCode
|
|
399
411
|
* @returns {Promise<Object>} Normalized user profile
|
|
400
412
|
*/
|
|
401
|
-
async fetchUserProfile(providerName, tokens) {
|
|
413
|
+
async fetchUserProfile(providerName, tokens, options = {}) {
|
|
402
414
|
const provider = this.getProvider(providerName);
|
|
403
415
|
const accessToken = tokens.access_token || tokens.accessToken;
|
|
404
416
|
|
|
@@ -406,19 +418,26 @@ class OAuthClient {
|
|
|
406
418
|
throw new OAuthError('Missing access_token for user profile retrieval', 'MISSING_TOKEN');
|
|
407
419
|
}
|
|
408
420
|
|
|
421
|
+
const requestTimeout = typeof options?.timeout === 'number'
|
|
422
|
+
? options.timeout
|
|
423
|
+
: (provider.timeout || this.timeout || 10000);
|
|
424
|
+
const signal = options?.signal;
|
|
425
|
+
|
|
409
426
|
let extraData = null;
|
|
410
427
|
if (typeof provider.fetchExtra === 'function') {
|
|
411
|
-
extraData = await provider.fetchExtra(tokens);
|
|
428
|
+
extraData = await provider.fetchExtra(tokens, { timeout: requestTimeout, signal });
|
|
412
429
|
}
|
|
413
430
|
|
|
414
431
|
let res;
|
|
415
432
|
try {
|
|
416
|
-
res = await
|
|
433
|
+
res = await fetchWithTimeout(provider.userInfoUrl, {
|
|
417
434
|
headers: {
|
|
418
435
|
Authorization: `Bearer ${accessToken}`,
|
|
419
436
|
Accept: 'application/json',
|
|
420
437
|
'User-Agent': 'Webspresso-OAuth',
|
|
421
438
|
},
|
|
439
|
+
timeout: requestTimeout,
|
|
440
|
+
signal,
|
|
422
441
|
});
|
|
423
442
|
} catch (err) {
|
|
424
443
|
throw new OAuthError(`User profile fetch failed: ${err.message}`, 'NETWORK_ERROR', err);
|
|
@@ -457,7 +476,7 @@ class OAuthClient {
|
|
|
457
476
|
* @param {string} [params.redirectUri] - Redirect URI
|
|
458
477
|
* @returns {Promise<{ provider: string, user: Object, tokens: Object, stateData: Object }>}
|
|
459
478
|
*/
|
|
460
|
-
async handleCallback(providerName, { code, state, redirectUri }) {
|
|
479
|
+
async handleCallback(providerName, { code, state, redirectUri, timeout, signal } = {}) {
|
|
461
480
|
if (!state) {
|
|
462
481
|
throw new OAuthError('Missing OAuth state in callback', 'INVALID_STATE');
|
|
463
482
|
}
|
|
@@ -483,10 +502,15 @@ class OAuthClient {
|
|
|
483
502
|
code,
|
|
484
503
|
redirectUri: effectiveRedirectUri,
|
|
485
504
|
codeVerifier,
|
|
505
|
+
timeout,
|
|
506
|
+
signal,
|
|
486
507
|
});
|
|
487
508
|
|
|
488
509
|
// Fetch normalized profile
|
|
489
|
-
const user = await this.fetchUserProfile(providerName, tokens
|
|
510
|
+
const user = await this.fetchUserProfile(providerName, tokens, {
|
|
511
|
+
timeout,
|
|
512
|
+
signal,
|
|
513
|
+
});
|
|
490
514
|
|
|
491
515
|
return {
|
|
492
516
|
provider: providerName,
|
package/core/auth/totp.js
CHANGED
|
@@ -163,20 +163,8 @@ function generateHotp(secret, counter, options = {}) {
|
|
|
163
163
|
return token;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
* @param {string} a
|
|
169
|
-
* @param {string} b
|
|
170
|
-
* @returns {boolean}
|
|
171
|
-
*/
|
|
172
|
-
function timingSafeTokenCompare(a, b) {
|
|
173
|
-
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
174
|
-
if (a.length !== b.length) return false;
|
|
175
|
-
|
|
176
|
-
const bufA = Buffer.from(a);
|
|
177
|
-
const bufB = Buffer.from(b);
|
|
178
|
-
return crypto.timingSafeEqual(bufA, bufB);
|
|
179
|
-
}
|
|
166
|
+
const { safeCompare } = require('../utils/crypto');
|
|
167
|
+
const timingSafeTokenCompare = safeCompare;
|
|
180
168
|
|
|
181
169
|
/**
|
|
182
170
|
* Verify HOTP token against counter
|
|
@@ -412,7 +400,7 @@ function verifyRecoveryCode(code, hashedCodes = []) {
|
|
|
412
400
|
continue;
|
|
413
401
|
}
|
|
414
402
|
const candidateBuf = Buffer.from(candidateHash, 'hex');
|
|
415
|
-
if (
|
|
403
|
+
if (safeCompare(targetBuf, candidateBuf)) {
|
|
416
404
|
matchedIndex = i;
|
|
417
405
|
break;
|
|
418
406
|
}
|
package/core/auth/user-agent.js
CHANGED
|
@@ -17,27 +17,7 @@
|
|
|
17
17
|
* @property {boolean} isBot
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
* Extract client IP address handling proxies
|
|
22
|
-
* @param {Object} req - Express / Node.js HTTP request
|
|
23
|
-
* @returns {string}
|
|
24
|
-
*/
|
|
25
|
-
function getClientIp(req) {
|
|
26
|
-
if (!req) return '127.0.0.1';
|
|
27
|
-
|
|
28
|
-
const forwarded = req.headers?.['x-forwarded-for'];
|
|
29
|
-
if (forwarded) {
|
|
30
|
-
const ips = typeof forwarded === 'string' ? forwarded.split(',') : forwarded;
|
|
31
|
-
const first = Array.isArray(ips) ? ips[0] : ips;
|
|
32
|
-
if (first) return first.trim();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return req.headers?.['x-real-ip'] ||
|
|
36
|
-
req.ip ||
|
|
37
|
-
req.connection?.remoteAddress ||
|
|
38
|
-
req.socket?.remoteAddress ||
|
|
39
|
-
'127.0.0.1';
|
|
40
|
-
}
|
|
20
|
+
const { getClientIp } = require('../utils/ip');
|
|
41
21
|
|
|
42
22
|
/**
|
|
43
23
|
* Parse raw User-Agent header string into structured device info
|
package/core/content/renderer.js
CHANGED
|
@@ -3,19 +3,7 @@
|
|
|
3
3
|
* @module core/content/renderer
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
* Escape HTML for text nodes (not for rich-text which uses | safe separately).
|
|
8
|
-
* @param {unknown} value
|
|
9
|
-
* @returns {string}
|
|
10
|
-
*/
|
|
11
|
-
function escapeHtml(value) {
|
|
12
|
-
if (value === null || value === undefined) return '';
|
|
13
|
-
return String(value)
|
|
14
|
-
.replace(/&/g, '&')
|
|
15
|
-
.replace(/</g, '<')
|
|
16
|
-
.replace(/>/g, '>')
|
|
17
|
-
.replace(/"/g, '"');
|
|
18
|
-
}
|
|
6
|
+
const { escapeHtml } = require('../utils/html');
|
|
19
7
|
|
|
20
8
|
/**
|
|
21
9
|
* @param {unknown} value
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const { normalizeError, toErrorResponseObject } = require('./normalize');
|
|
7
7
|
const { RequestAbortedError } = require('./domain');
|
|
8
|
+
const { escapeHtml } = require('../utils/html');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Check if the request prefers a JSON error response
|
|
@@ -60,14 +61,7 @@ function renderDefaultErrorHtml(err, isDev) {
|
|
|
60
61
|
</html>`;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
return String(str || '')
|
|
65
|
-
.replace(/&/g, '&')
|
|
66
|
-
.replace(/</g, '<')
|
|
67
|
-
.replace(/>/g, '>')
|
|
68
|
-
.replace(/"/g, '"')
|
|
69
|
-
.replace(/'/g, ''');
|
|
70
|
-
}
|
|
64
|
+
|
|
71
65
|
|
|
72
66
|
/**
|
|
73
67
|
* Create Webspresso Central Error Handler Middleware
|
|
@@ -4,15 +4,7 @@
|
|
|
4
4
|
* @module core/forms/form-builder
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
if (str === null || str === undefined) return '';
|
|
9
|
-
return String(str)
|
|
10
|
-
.replace(/&/g, '&')
|
|
11
|
-
.replace(/</g, '<')
|
|
12
|
-
.replace(/>/g, '>')
|
|
13
|
-
.replace(/"/g, '"')
|
|
14
|
-
.replace(/'/g, ''');
|
|
15
|
-
}
|
|
7
|
+
const { escapeHtml } = require('../utils/html');
|
|
16
8
|
|
|
17
9
|
/**
|
|
18
10
|
* Render a single form field with label, input, and validation error message
|
package/core/orm/index.js
CHANGED
|
@@ -392,4 +392,9 @@ module.exports = {
|
|
|
392
392
|
// Schema Diff & Makemigrations (Django-inspired)
|
|
393
393
|
diffSchema: require('./migrations/diff').diffSchema,
|
|
394
394
|
makeMigrations: require('./migrations/diff').makeMigrations,
|
|
395
|
+
// Universal Query Filters (REST Resources & Admin Panel)
|
|
396
|
+
buildFilteredQuery: require('./query-filters').buildFilteredQuery,
|
|
397
|
+
applyColumnFilters: require('./query-filters').applyColumnFilters,
|
|
398
|
+
applyFilterCondition: require('./query-filters').applyFilterCondition,
|
|
399
|
+
queryFilters: require('./query-filters'),
|
|
395
400
|
};
|