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.
Files changed (60) hide show
  1. package/bin/commands/dev.js +3 -2
  2. package/bin/utils/orm-map-html.js +2 -8
  3. package/core/auth/jwt.js +2 -4
  4. package/core/auth/oauth.js +38 -14
  5. package/core/auth/totp.js +3 -15
  6. package/core/auth/user-agent.js +1 -21
  7. package/core/content/renderer.js +1 -13
  8. package/core/errors/middleware.js +2 -8
  9. package/core/forms/form-builder.js +1 -9
  10. package/core/orm/index.js +5 -0
  11. package/core/orm/query-filters.js +393 -0
  12. package/core/shutdown/shutdown-manager.js +16 -16
  13. package/core/utils/crypto.js +73 -0
  14. package/core/utils/escape-html.js +7 -0
  15. package/core/utils/fetch.js +120 -0
  16. package/core/utils/get-client-ip.js +6 -0
  17. package/core/utils/html.js +72 -0
  18. package/core/utils/ip.js +86 -0
  19. package/core/utils/safe-compare.js +3 -0
  20. package/index.d.ts +11 -1
  21. package/index.js +10 -0
  22. package/package.json +1 -1
  23. package/plugins/admin-panel/auth.js +15 -7
  24. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  25. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  26. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  27. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  28. package/plugins/admin-panel/core/api-extensions.js +1 -75
  29. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  30. package/plugins/api-key/crypto.js +1 -24
  31. package/plugins/audit-log/api-handlers.js +11 -1
  32. package/plugins/audit-log/middleware.js +11 -2
  33. package/plugins/basic-auth/index.js +2 -26
  34. package/plugins/cors.js +2 -2
  35. package/plugins/csrf/index.js +3 -15
  36. package/plugins/file-manager/admin-component.js +3 -1
  37. package/plugins/file-manager/core.js +34 -4
  38. package/plugins/mcp/auth.js +1 -13
  39. package/plugins/mcp/index.js +23 -1
  40. package/plugins/mcp/transports/sse.js +10 -1
  41. package/plugins/orm-cache-admin/admin-component.js +3 -2
  42. package/plugins/polar/src/api.js +9 -2
  43. package/plugins/polar/src/webhooks.js +3 -8
  44. package/plugins/recaptcha/helpers.js +1 -7
  45. package/plugins/recaptcha/verify.js +37 -23
  46. package/plugins/redirect/index.js +51 -3
  47. package/plugins/rest-resources/index.js +1 -207
  48. package/plugins/schema-explorer/admin-component.js +4 -2
  49. package/plugins/schema-explorer.js +40 -29
  50. package/plugins/seo-checker/index.js +5 -2
  51. package/plugins/site-analytics/tracking.js +19 -16
  52. package/plugins/sitemap.js +18 -15
  53. package/plugins/swagger.js +9 -14
  54. package/plugins/webhooks/crypto.js +1 -18
  55. package/plugins/webhooks/dispatcher.js +6 -1
  56. package/plugins/webhooks/matcher.js +25 -10
  57. package/plugins/webhooks/sender.js +32 -2
  58. package/src/dev-reload/index.js +47 -6
  59. package/src/helpers.js +21 -9
  60. package/src/server.js +2 -2
@@ -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 common directories
17
- const watchPaths = ['pages', 'models', 'views'];
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
- function escapeHtml(s) {
665
- if (s == null) return '';
666
- return String(s)
667
- .replace(/&/g, '&')
668
- .replace(/</g, '&lt;')
669
- .replace(/>/g, '&gt;')
670
- .replace(/"/g, '&quot;');
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
 
@@ -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 res = await fetch('https://api.github.com/user/emails', {
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
- const bufA = Buffer.from(signature);
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 fetch(provider.tokenUrl, {
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 fetch(provider.userInfoUrl, {
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
- * Timing-safe token equality check
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 (crypto.timingSafeEqual(targetBuf, candidateBuf)) {
403
+ if (safeCompare(targetBuf, candidateBuf)) {
416
404
  matchedIndex = i;
417
405
  break;
418
406
  }
@@ -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
@@ -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, '&amp;')
15
- .replace(/</g, '&lt;')
16
- .replace(/>/g, '&gt;')
17
- .replace(/"/g, '&quot;');
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
- function escapeHtml(str) {
64
- return String(str || '')
65
- .replace(/&/g, '&amp;')
66
- .replace(/</g, '&lt;')
67
- .replace(/>/g, '&gt;')
68
- .replace(/"/g, '&quot;')
69
- .replace(/'/g, '&#39;');
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
- function escapeHtml(str) {
8
- if (str === null || str === undefined) return '';
9
- return String(str)
10
- .replace(/&/g, '&amp;')
11
- .replace(/</g, '&lt;')
12
- .replace(/>/g, '&gt;')
13
- .replace(/"/g, '&quot;')
14
- .replace(/'/g, '&#039;');
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
  };