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/.agents/ORM.md
CHANGED
|
@@ -51,7 +51,9 @@ import { defineModel, zdb } from 'webspresso';
|
|
|
51
51
|
export default defineModel({
|
|
52
52
|
name: 'Product',
|
|
53
53
|
table: 'products',
|
|
54
|
-
|
|
54
|
+
timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
|
|
55
|
+
softDelete: true, // auto-injects deletedAt & manages soft-delete scope
|
|
56
|
+
schema: {
|
|
55
57
|
id: zdb.id(),
|
|
56
58
|
name: zdb.string().min(2),
|
|
57
59
|
slug: zdb.string().unique(),
|
|
@@ -61,9 +63,7 @@ export default defineModel({
|
|
|
61
63
|
metadata: zdb.json({ optional: true }),
|
|
62
64
|
category_id: zdb.integer(),
|
|
63
65
|
active: zdb.boolean({ default: true }),
|
|
64
|
-
|
|
65
|
-
updated_at: zdb.timestamp({ auto: 'update' }),
|
|
66
|
-
}),
|
|
66
|
+
},
|
|
67
67
|
relations: {
|
|
68
68
|
category: { type: 'belongsTo', model: 'Category', foreignKey: 'category_id' },
|
|
69
69
|
reviews: { type: 'hasMany', model: 'Review', foreignKey: 'product_id' },
|
|
@@ -81,6 +81,13 @@ export default defineModel({
|
|
|
81
81
|
});
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
### Automatic Timestamps & Soft Deletes (`timestamps`, `softDelete`)
|
|
85
|
+
Instead of manually defining boilerplate timestamp and soft delete columns in `schema`, configure them declaratively:
|
|
86
|
+
- **Top-level / Options:** `timestamps: true` (or `options: { timestamps: true }`) automatically injects `createdAt` and `updatedAt` into the Zod schema, validation rules, column map, and Knex migrations.
|
|
87
|
+
- **Soft Deletes:** `softDelete: true` (or `softDeletes: true`) automatically injects `deletedAt` (nullable/optional) into the schema and activates the repository `whereNull('deletedAt')` soft-delete scope.
|
|
88
|
+
- **Custom column names & snake_case:** `timestamps: { snakeCase: true }` or `timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }`, `softDelete: { deletedAt: 'deleted_at' }`.
|
|
89
|
+
- **Explicit Schema Spread:** `...zdb.timestamps()` and `...zdb.softDeletes()` are also supported for explicit schema definitions.
|
|
90
|
+
|
|
84
91
|
### Supported `zdb` Data Types
|
|
85
92
|
- `zdb.id()` — Primary key (BigInt / Auto-increment)
|
|
86
93
|
- `zdb.string({ max, unique, optional, sortable })` — String / VarChar
|
|
@@ -90,6 +97,8 @@ export default defineModel({
|
|
|
90
97
|
- `zdb.file()` — File asset URL / upload path (non-sortable by default)
|
|
91
98
|
- `zdb.json()` — Serialized JSON column (non-sortable by default)
|
|
92
99
|
- `zdb.timestamp({ auto, sortable })` — Timestamp (`auto: 'create'` / `auto: 'update'`)
|
|
100
|
+
- `zdb.timestamps({ snakeCase })` — Timestamp pair (`createdAt`, `updatedAt`)
|
|
101
|
+
- `zdb.softDelete()`, `zdb.softDeletes({ snakeCase })` — Soft delete timestamp (`deletedAt`)
|
|
93
102
|
|
|
94
103
|
*Note: All `zdb` types support chainable `.config({ sortable: boolean })` or options object `{ sortable: boolean }`.*
|
|
95
104
|
|
|
@@ -21,8 +21,10 @@ module.exports = defineModel({
|
|
|
21
21
|
name: 'Product',
|
|
22
22
|
table: 'products',
|
|
23
23
|
primaryKey: 'id',
|
|
24
|
+
timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
|
|
25
|
+
softDelete: true, // auto-injects deletedAt & enables soft-delete scope
|
|
24
26
|
|
|
25
|
-
schema:
|
|
27
|
+
schema: {
|
|
26
28
|
id: zdb.id(),
|
|
27
29
|
name: zdb.string().min(1).max(255),
|
|
28
30
|
slug: zdb.string().unique(),
|
|
@@ -30,10 +32,7 @@ module.exports = defineModel({
|
|
|
30
32
|
in_stock: zdb.boolean().default(true),
|
|
31
33
|
metadata: zdb.json().optional(),
|
|
32
34
|
user_id: zdb.foreignKey('users.id'),
|
|
33
|
-
|
|
34
|
-
updated_at: zdb.timestamp().autoUpdate(),
|
|
35
|
-
deleted_at: zdb.timestamp().nullable(),
|
|
36
|
-
}),
|
|
35
|
+
},
|
|
37
36
|
|
|
38
37
|
relations: {
|
|
39
38
|
user: {
|
package/README.md
CHANGED
|
@@ -115,13 +115,13 @@ const { defineModel, zdb } = require('webspresso');
|
|
|
115
115
|
module.exports = defineModel({
|
|
116
116
|
name: 'Note',
|
|
117
117
|
table: 'notes',
|
|
118
|
+
timestamps: true, // auto-injects createdAt & updatedAt into schema & scopes
|
|
119
|
+
softDelete: true, // auto-injects deletedAt & enables soft-delete scope
|
|
118
120
|
schema: {
|
|
119
121
|
id: zdb.id(),
|
|
120
122
|
title: zdb.string(200),
|
|
121
123
|
content: zdb.text(),
|
|
122
124
|
userId: zdb.integer(),
|
|
123
|
-
...zdb.timestamps(),
|
|
124
|
-
...zdb.softDeletes(),
|
|
125
125
|
},
|
|
126
126
|
relations: {
|
|
127
127
|
user: { type: 'belongsTo', model: 'User', foreignKey: 'userId' },
|
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,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const crypto = require('crypto');
|
|
8
|
+
const { safeCompare } = require('../utils/crypto');
|
|
9
|
+
|
|
10
|
+
const HS256_HEADER_BASE64URL = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Base64URL encode string or Buffer
|
|
@@ -12,12 +15,9 @@ const crypto = require('crypto');
|
|
|
12
15
|
* @returns {string}
|
|
13
16
|
*/
|
|
14
17
|
function base64UrlEncode(input) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
.toString('
|
|
18
|
-
.replace(/=/g, '')
|
|
19
|
-
.replace(/\+/g, '-')
|
|
20
|
-
.replace(/\//g, '_');
|
|
18
|
+
return Buffer.isBuffer(input)
|
|
19
|
+
? input.toString('base64url')
|
|
20
|
+
: Buffer.from(String(input), 'utf8').toString('base64url');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -26,11 +26,7 @@ function base64UrlEncode(input) {
|
|
|
26
26
|
* @returns {string}
|
|
27
27
|
*/
|
|
28
28
|
function base64UrlDecode(input) {
|
|
29
|
-
|
|
30
|
-
while (base64.length % 4 !== 0) {
|
|
31
|
-
base64 += '=';
|
|
32
|
-
}
|
|
33
|
-
return Buffer.from(base64, 'base64').toString('utf8');
|
|
29
|
+
return Buffer.from(String(input), 'base64url').toString('utf8');
|
|
34
30
|
}
|
|
35
31
|
|
|
36
32
|
/**
|
|
@@ -110,16 +106,14 @@ function signJwt(payload, secret, options = {}) {
|
|
|
110
106
|
...payload,
|
|
111
107
|
};
|
|
112
108
|
|
|
113
|
-
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
109
|
+
const encodedHeader = algorithm === 'HS256' ? HS256_HEADER_BASE64URL : base64UrlEncode(JSON.stringify(header));
|
|
114
110
|
const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
|
|
115
111
|
|
|
116
112
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
117
|
-
const
|
|
113
|
+
const encodedSignature = crypto
|
|
118
114
|
.createHmac('sha256', secret)
|
|
119
115
|
.update(signingInput)
|
|
120
|
-
.digest();
|
|
121
|
-
|
|
122
|
-
const encodedSignature = base64UrlEncode(signature);
|
|
116
|
+
.digest('base64url');
|
|
123
117
|
|
|
124
118
|
return `${signingInput}.${encodedSignature}`;
|
|
125
119
|
}
|
|
@@ -177,12 +171,9 @@ function verifyJwt(token, secret, options = {}) {
|
|
|
177
171
|
.update(signingInput)
|
|
178
172
|
.digest();
|
|
179
173
|
|
|
180
|
-
const actualSignatureBuf = Buffer.from(encodedSignature
|
|
174
|
+
const actualSignatureBuf = Buffer.from(encodedSignature, 'base64url');
|
|
181
175
|
|
|
182
|
-
if (
|
|
183
|
-
expectedSignatureBuf.length !== actualSignatureBuf.length ||
|
|
184
|
-
!crypto.timingSafeEqual(expectedSignatureBuf, actualSignatureBuf)
|
|
185
|
-
) {
|
|
176
|
+
if (!safeCompare(expectedSignatureBuf, actualSignatureBuf)) {
|
|
186
177
|
throw new Error('Invalid JWT signature');
|
|
187
178
|
}
|
|
188
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,29 @@
|
|
|
17
17
|
* @property {boolean} isBot
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
const { getClientIp } = require('../utils/ip');
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* Parse raw User-Agent header string into structured device info
|
|
24
|
+
* Zero external dependencies — pure regex extraction
|
|
25
|
+
*
|
|
26
|
+
* @param {string} [userAgent='']
|
|
27
|
+
* @returns {DeviceInfo}
|
|
24
28
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
}
|
|
29
|
+
const MAX_UA_CACHE = 512;
|
|
30
|
+
const uaCache = new Map();
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
const EMPTY_DEVICE_INFO = Object.freeze({
|
|
33
|
+
browser: 'Unknown',
|
|
34
|
+
os: 'Unknown',
|
|
35
|
+
device: 'unknown',
|
|
36
|
+
isMobile: false,
|
|
37
|
+
isTablet: false,
|
|
38
|
+
isDesktop: false,
|
|
39
|
+
isBot: false,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const BOT_PATTERN = /googlebot|bingbot|yandex|baiduspider|duckduckbot|slurp|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/|slackbot|vkshare|w3c_validator|whatsapp|curl|wget|postman/i;
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
45
|
* Parse raw User-Agent header string into structured device info
|
|
@@ -50,34 +52,30 @@ function parseUserAgent(userAgent = '') {
|
|
|
50
52
|
const ua = String(userAgent || '').trim();
|
|
51
53
|
|
|
52
54
|
if (!ua) {
|
|
53
|
-
return {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
isDesktop: false,
|
|
60
|
-
isBot: false,
|
|
61
|
-
};
|
|
55
|
+
return { ...EMPTY_DEVICE_INFO };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cached = uaCache.get(ua);
|
|
59
|
+
if (cached) {
|
|
60
|
+
return { ...cached };
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
// 1. Detect Bots / Crawlers
|
|
65
|
-
const
|
|
66
|
-
const isBot = botPattern.test(ua);
|
|
64
|
+
const isBot = BOT_PATTERN.test(ua);
|
|
67
65
|
|
|
68
66
|
// 2. Detect Operating System
|
|
69
67
|
let os = 'Unknown';
|
|
68
|
+
let match;
|
|
70
69
|
if (/iPad/i.test(ua)) {
|
|
71
|
-
|
|
70
|
+
match = ua.match(/OS ([\d_\.]+)/i);
|
|
72
71
|
os = match ? `iPadOS ${match[1].replace(/_/g, '.')}` : 'iPadOS';
|
|
73
72
|
} else if (/iPhone|iPod/i.test(ua)) {
|
|
74
|
-
|
|
73
|
+
match = ua.match(/OS ([\d_\.]+)/i);
|
|
75
74
|
os = match ? `iOS ${match[1].replace(/_/g, '.')}` : 'iOS';
|
|
76
|
-
} else if (/Android/i
|
|
77
|
-
|
|
78
|
-
os = match ? `Android ${match[1]}` : 'Android';
|
|
75
|
+
} else if ((match = ua.match(/Android (\d+(\.\d+)?)/i))) {
|
|
76
|
+
os = `Android ${match[1]}`;
|
|
79
77
|
} else if (/Macintosh|Mac OS X/i.test(ua)) {
|
|
80
|
-
|
|
78
|
+
match = ua.match(/Mac OS X ([\d_\.]+)/i);
|
|
81
79
|
if (match) {
|
|
82
80
|
const ver = match[1].replace(/_/g, '.');
|
|
83
81
|
os = `macOS ${ver}`;
|
|
@@ -100,30 +98,22 @@ function parseUserAgent(userAgent = '') {
|
|
|
100
98
|
|
|
101
99
|
// 3. Detect Browser
|
|
102
100
|
let browser = 'Unknown';
|
|
103
|
-
if (/Edg(e|A|iOS)?\/(\d+)/i
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
browser = `
|
|
109
|
-
} else if (/
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
browser = `
|
|
115
|
-
} else if (/
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const match = ua.match(/Chrome\/(\d+)/i);
|
|
120
|
-
browser = `Chrome ${match ? match[1] : ''}`.trim();
|
|
121
|
-
} else if (/Firefox\/(\d+)/i.test(ua) || /FxiOS\/(\d+)/i.test(ua)) {
|
|
122
|
-
const match = ua.match(/(?:Firefox|FxiOS)\/(\d+)/i);
|
|
123
|
-
browser = `Firefox ${match ? match[1] : ''}`.trim();
|
|
124
|
-
} else if (/Safari/i.test(ua) && /Version\/(\d+)/i.test(ua)) {
|
|
125
|
-
const match = ua.match(/Version\/(\d+)/i);
|
|
126
|
-
browser = `Safari ${match ? match[1] : ''}`.trim();
|
|
101
|
+
if ((match = ua.match(/Edg(e|A|iOS)?\/(\d+)/i))) {
|
|
102
|
+
browser = `Edge ${match[2] || ''}`.trim();
|
|
103
|
+
} else if ((match = ua.match(/OPR\/(\d+)/i))) {
|
|
104
|
+
browser = `Opera ${match[1] || ''}`.trim();
|
|
105
|
+
} else if ((match = ua.match(/SamsungBrowser\/(\d+)/i))) {
|
|
106
|
+
browser = `Samsung Internet ${match[1] || ''}`.trim();
|
|
107
|
+
} else if ((match = ua.match(/Brave\/(\d+)/i))) {
|
|
108
|
+
browser = `Brave ${match[1] || ''}`.trim();
|
|
109
|
+
} else if ((match = ua.match(/Vivaldi\/(\d+)/i))) {
|
|
110
|
+
browser = `Vivaldi ${match[1] || ''}`.trim();
|
|
111
|
+
} else if (!/Chromium/i.test(ua) && (match = ua.match(/Chrome\/(\d+)/i))) {
|
|
112
|
+
browser = `Chrome ${match[1] || ''}`.trim();
|
|
113
|
+
} else if ((match = ua.match(/(?:Firefox|FxiOS)\/(\d+)/i))) {
|
|
114
|
+
browser = `Firefox ${match[1] || ''}`.trim();
|
|
115
|
+
} else if ((match = ua.match(/Version\/(\d+)/i)) && /Safari/i.test(ua)) {
|
|
116
|
+
browser = `Safari ${match[1] || ''}`.trim();
|
|
127
117
|
} else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) {
|
|
128
118
|
browser = 'Safari';
|
|
129
119
|
} else if (/MSIE (\d+)/i.test(ua) || (/Trident/i.test(ua) && /rv:(\d+)/i.test(ua))) {
|
|
@@ -144,7 +134,7 @@ function parseUserAgent(userAgent = '') {
|
|
|
144
134
|
else if (isMobile) device = 'mobile';
|
|
145
135
|
else if (isDesktop) device = 'desktop';
|
|
146
136
|
|
|
147
|
-
|
|
137
|
+
const result = {
|
|
148
138
|
browser,
|
|
149
139
|
os,
|
|
150
140
|
device,
|
|
@@ -153,9 +143,18 @@ function parseUserAgent(userAgent = '') {
|
|
|
153
143
|
isDesktop,
|
|
154
144
|
isBot,
|
|
155
145
|
};
|
|
146
|
+
|
|
147
|
+
if (uaCache.size >= MAX_UA_CACHE) {
|
|
148
|
+
const oldestKey = uaCache.keys().next().value;
|
|
149
|
+
uaCache.delete(oldestKey);
|
|
150
|
+
}
|
|
151
|
+
uaCache.set(ua, result);
|
|
152
|
+
|
|
153
|
+
return { ...result };
|
|
156
154
|
}
|
|
157
155
|
|
|
158
156
|
module.exports = {
|
|
159
157
|
getClientIp,
|
|
160
158
|
parseUserAgent,
|
|
161
159
|
};
|
|
160
|
+
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const zlib = require('zlib');
|
|
8
|
-
const
|
|
8
|
+
const negotiate = require('./negotiate');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Create a zlib compression transform stream based on encoding
|
|
@@ -23,7 +23,7 @@ function createCompressionStream(encoding, options = {}) {
|
|
|
23
23
|
|
|
24
24
|
switch (enc) {
|
|
25
25
|
case 'br': {
|
|
26
|
-
if (!supportsBrotli()) {
|
|
26
|
+
if (options.supportsBrotli === false || !negotiate.supportsBrotli()) {
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
29
|
const brotliOpts = { ...(options.brotli || {}) };
|
|
@@ -55,6 +55,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
55
55
|
const customFilter = typeof options.filter === 'function' ? options.filter : null;
|
|
56
56
|
|
|
57
57
|
const comp = compression({
|
|
58
|
+
...options,
|
|
58
59
|
threshold,
|
|
59
60
|
brotli: options.brotli,
|
|
60
61
|
level: options.level,
|
|
@@ -73,7 +74,6 @@ function createCompressionMiddleware(options = {}) {
|
|
|
73
74
|
}
|
|
74
75
|
return compression.filter(req, res);
|
|
75
76
|
},
|
|
76
|
-
...options,
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
return function compressionMiddleware(req, res, next) {
|
|
@@ -69,6 +69,9 @@ function parseAcceptEncoding(header) {
|
|
|
69
69
|
return results;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
const DEFAULT_SUPPORTED_ENCODINGS = Object.freeze(getDefaultSupportedEncodings());
|
|
73
|
+
const DEFAULT_SUPPORTED_STRING = DEFAULT_SUPPORTED_ENCODINGS.join(',');
|
|
74
|
+
|
|
72
75
|
const selectEncodingCache = new Map();
|
|
73
76
|
const MAX_ENCODING_CACHE_SIZE = 128;
|
|
74
77
|
|
|
@@ -83,16 +86,20 @@ function selectEncoding(acceptEncoding, supportedEncodings) {
|
|
|
83
86
|
return 'identity';
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
const isDefault = !supportedEncodings || !Array.isArray(supportedEncodings) || supportedEncodings.length === 0;
|
|
90
|
+
const cacheKey = isDefault
|
|
91
|
+
? `${acceptEncoding}|${DEFAULT_SUPPORTED_STRING}`
|
|
92
|
+
: `${acceptEncoding}|${supportedEncodings.join(',')}`;
|
|
89
93
|
|
|
90
|
-
const cacheKey = `${acceptEncoding}|${supported.join(',')}`;
|
|
91
94
|
const cached = selectEncodingCache.get(cacheKey);
|
|
92
95
|
if (cached !== undefined) {
|
|
93
96
|
return cached;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
const supported = isDefault
|
|
100
|
+
? DEFAULT_SUPPORTED_ENCODINGS
|
|
101
|
+
: supportedEncodings.map(e => e.toLowerCase()).filter(e => e !== 'br' || supportsBrotli());
|
|
102
|
+
|
|
96
103
|
const clientPreferences = parseAcceptEncoding(acceptEncoding);
|
|
97
104
|
if (clientPreferences.length === 0) {
|
|
98
105
|
if (selectEncodingCache.size < MAX_ENCODING_CACHE_SIZE) {
|
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
|