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
|
@@ -4,32 +4,7 @@
|
|
|
4
4
|
* @module plugins/basic-auth
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Compare two strings in constant time to prevent timing attacks.
|
|
11
|
-
* Uses dummy buffer comparison if lengths differ to avoid leaking length via response timing.
|
|
12
|
-
*
|
|
13
|
-
* @param {string} a
|
|
14
|
-
* @param {string} b
|
|
15
|
-
* @returns {boolean}
|
|
16
|
-
*/
|
|
17
|
-
function safeCompare(a, b) {
|
|
18
|
-
if (typeof a !== 'string' || typeof b !== 'string') {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const bufA = Buffer.from(a, 'utf8');
|
|
23
|
-
const bufB = Buffer.from(b, 'utf8');
|
|
24
|
-
|
|
25
|
-
if (bufA.length !== bufB.length) {
|
|
26
|
-
// Perform dummy timing-safe comparison with itself to preserve constant execution time
|
|
27
|
-
crypto.timingSafeEqual(bufA, bufA);
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return crypto.timingSafeEqual(bufA, bufB);
|
|
32
|
-
}
|
|
7
|
+
const { safeCompare } = require('../../core/utils/crypto');
|
|
33
8
|
|
|
34
9
|
/**
|
|
35
10
|
* Parse an HTTP Authorization header for Basic credentials.
|
|
@@ -69,6 +44,7 @@ function parseBasicAuthHeader(header) {
|
|
|
69
44
|
* @returns {{ username: string, password: string } | null}
|
|
70
45
|
*/
|
|
71
46
|
function extractCredentials(req) {
|
|
47
|
+
if (!req || !req.headers) return null;
|
|
72
48
|
const header = req.headers.authorization || req.headers.Authorization;
|
|
73
49
|
return parseBasicAuthHeader(header);
|
|
74
50
|
}
|
package/plugins/cors.js
CHANGED
|
@@ -105,7 +105,7 @@ function createCorsMiddleware(options = {}) {
|
|
|
105
105
|
return next();
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
const reqOrigin = req.headers
|
|
108
|
+
const reqOrigin = req.headers?.origin;
|
|
109
109
|
|
|
110
110
|
isOriginAllowed(reqOrigin, (err, allowedOrigin) => {
|
|
111
111
|
if (err) {
|
|
@@ -134,7 +134,7 @@ function createCorsMiddleware(options = {}) {
|
|
|
134
134
|
}
|
|
135
135
|
if (allowedHeadersStr) {
|
|
136
136
|
res.setHeader('Access-Control-Allow-Headers', allowedHeadersStr);
|
|
137
|
-
} else if (req.headers['access-control-request-headers']) {
|
|
137
|
+
} else if (req.headers?.['access-control-request-headers']) {
|
|
138
138
|
res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers']);
|
|
139
139
|
}
|
|
140
140
|
if (config.maxAge) {
|
package/plugins/csrf/index.js
CHANGED
|
@@ -15,21 +15,7 @@ const NOOP = () => {};
|
|
|
15
15
|
const EMPTY_OBJECT = Object.freeze({});
|
|
16
16
|
const ignoreRegexCache = new Map();
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
* Compare two strings in constant time to prevent timing attacks
|
|
20
|
-
* @param {string} a
|
|
21
|
-
* @param {string} b
|
|
22
|
-
* @returns {boolean}
|
|
23
|
-
*/
|
|
24
|
-
function safeCompare(a, b) {
|
|
25
|
-
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
26
|
-
if (a.length !== b.length) return false;
|
|
27
|
-
try {
|
|
28
|
-
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
29
|
-
} catch (err) {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
18
|
+
const { safeCompare } = require('../../core/utils/crypto');
|
|
33
19
|
|
|
34
20
|
/**
|
|
35
21
|
* Check if the request path matches any ignore patterns
|
|
@@ -241,3 +227,5 @@ function csrfPlugin(options = {}) {
|
|
|
241
227
|
|
|
242
228
|
module.exports = csrfPlugin;
|
|
243
229
|
module.exports.csrfPlugin = csrfPlugin;
|
|
230
|
+
module.exports.safeCompare = safeCompare;
|
|
231
|
+
csrfPlugin.safeCompare = safeCompare;
|
|
@@ -173,10 +173,12 @@ function generateFileManagerComponent(options = {}) {
|
|
|
173
173
|
|
|
174
174
|
var uploadUrl = (window.__ADMIN_PATH__ || '/_admin') + '/api' + API + '/upload?path=' + encodeURIComponent(currentPath);
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
var fetchFn = (typeof window !== 'undefined' && window.Admin && window.Admin.fetch) ? window.Admin.fetch : fetch;
|
|
177
|
+
fetchFn(uploadUrl, {
|
|
177
178
|
method: 'POST',
|
|
178
179
|
body: fd,
|
|
179
180
|
credentials: 'include',
|
|
181
|
+
timeout: 60000,
|
|
180
182
|
}).then(function(r) { return r.json(); }).then(function(res) {
|
|
181
183
|
uploading = false;
|
|
182
184
|
uploadStatus = '';
|
|
@@ -137,7 +137,12 @@ function resolveSafePath(baseDir, relPath = '') {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
// Prevent URL decoded traversal attempts
|
|
140
|
-
|
|
140
|
+
let decodedPath;
|
|
141
|
+
try {
|
|
142
|
+
decodedPath = decodeURIComponent(relPath);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
throw new ValidationError('Malformed URI path sequence', 400);
|
|
145
|
+
}
|
|
141
146
|
if (decodedPath.includes('\0')) {
|
|
142
147
|
throw new SecurityError('Null byte detected in path', 400);
|
|
143
148
|
}
|
|
@@ -237,7 +242,7 @@ function buildBreadcrumbs(relPath) {
|
|
|
237
242
|
*/
|
|
238
243
|
async function listFiles(baseDir, relPath = '', options = {}) {
|
|
239
244
|
const targetDir = resolveSafePath(baseDir, relPath);
|
|
240
|
-
const normalizedRel = path.relative(path.resolve(baseDir), targetDir).replace(/\\/g, '');
|
|
245
|
+
const normalizedRel = path.relative(path.resolve(baseDir), targetDir).replace(/\\/g, '/');
|
|
241
246
|
const publicBase = normalizePublicBase(options.publicBasePath);
|
|
242
247
|
|
|
243
248
|
let dirents;
|
|
@@ -456,6 +461,7 @@ async function deleteItem(baseDir, itemRelPath) {
|
|
|
456
461
|
await fs.unlink(targetFullPath);
|
|
457
462
|
}
|
|
458
463
|
|
|
464
|
+
invalidateStorageCache(baseDir);
|
|
459
465
|
return { success: true, path: itemRelPath };
|
|
460
466
|
}
|
|
461
467
|
|
|
@@ -487,12 +493,33 @@ async function moveItem(baseDir, sourceRelPath, destDirRelPath) {
|
|
|
487
493
|
return { success: true, oldPath: sourceRelPath, newPath: newRelPath };
|
|
488
494
|
}
|
|
489
495
|
|
|
496
|
+
const storageCache = new Map();
|
|
497
|
+
const STORAGE_CACHE_TTL = 15000; // 15 seconds
|
|
498
|
+
|
|
499
|
+
function invalidateStorageCache(baseDir) {
|
|
500
|
+
if (baseDir) {
|
|
501
|
+
storageCache.delete(path.resolve(baseDir));
|
|
502
|
+
} else {
|
|
503
|
+
storageCache.clear();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
490
507
|
/**
|
|
491
508
|
* Calculate total disk usage in bytes under baseDir
|
|
492
509
|
* @param {string} baseDir
|
|
510
|
+
* @param {Object} [options]
|
|
511
|
+
* @param {boolean} [options.forceRefresh=false]
|
|
493
512
|
* @returns {Promise<number>}
|
|
494
513
|
*/
|
|
495
|
-
async function calculateStorageUsage(baseDir) {
|
|
514
|
+
async function calculateStorageUsage(baseDir, options = {}) {
|
|
515
|
+
const resolvedBase = path.resolve(baseDir);
|
|
516
|
+
const now = Date.now();
|
|
517
|
+
const cached = storageCache.get(resolvedBase);
|
|
518
|
+
|
|
519
|
+
if (!options.forceRefresh && cached && (now - cached.timestamp) < STORAGE_CACHE_TTL) {
|
|
520
|
+
return cached.bytes;
|
|
521
|
+
}
|
|
522
|
+
|
|
496
523
|
let totalBytes = 0;
|
|
497
524
|
async function scan(dir) {
|
|
498
525
|
try {
|
|
@@ -509,7 +536,8 @@ async function calculateStorageUsage(baseDir) {
|
|
|
509
536
|
}
|
|
510
537
|
} catch (e) {}
|
|
511
538
|
}
|
|
512
|
-
await scan(
|
|
539
|
+
await scan(resolvedBase);
|
|
540
|
+
storageCache.set(resolvedBase, { bytes: totalBytes, timestamp: now });
|
|
513
541
|
return totalBytes;
|
|
514
542
|
}
|
|
515
543
|
|
|
@@ -568,6 +596,7 @@ async function saveUploadedFile(baseDir, targetDirRelPath, buffer, originalName,
|
|
|
568
596
|
resolveSafePath(baseDir, path.relative(baseDir, targetPath));
|
|
569
597
|
|
|
570
598
|
await fs.writeFile(targetPath, finalBuffer);
|
|
599
|
+
invalidateStorageCache(baseDir);
|
|
571
600
|
|
|
572
601
|
const relFilePath = path.relative(path.resolve(baseDir), targetPath).replace(/\\/g, '/');
|
|
573
602
|
const publicBase = normalizePublicBase(publicBasePath);
|
|
@@ -598,4 +627,5 @@ module.exports = {
|
|
|
598
627
|
moveItem,
|
|
599
628
|
saveUploadedFile,
|
|
600
629
|
calculateStorageUsage,
|
|
630
|
+
invalidateStorageCache,
|
|
601
631
|
};
|
package/plugins/mcp/auth.js
CHANGED
|
@@ -6,19 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const crypto = require('crypto');
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
* Constant-time string comparison to avoid timing attacks
|
|
11
|
-
* @param {string} a
|
|
12
|
-
* @param {string} b
|
|
13
|
-
* @returns {boolean}
|
|
14
|
-
*/
|
|
15
|
-
function safeEqual(a, b) {
|
|
16
|
-
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
17
|
-
const bufA = Buffer.from(a);
|
|
18
|
-
const bufB = Buffer.from(b);
|
|
19
|
-
if (bufA.length !== bufB.length) return false;
|
|
20
|
-
return crypto.timingSafeEqual(bufA, bufB);
|
|
21
|
-
}
|
|
9
|
+
const { safeCompare: safeEqual } = require('../../core/utils/crypto');
|
|
22
10
|
|
|
23
11
|
/**
|
|
24
12
|
* Check if incoming request is from localhost
|
package/plugins/mcp/index.js
CHANGED
|
@@ -52,6 +52,7 @@ function mcpPlugin(options = {}) {
|
|
|
52
52
|
} = options;
|
|
53
53
|
|
|
54
54
|
let serverInstance = null;
|
|
55
|
+
let sseTransport = null;
|
|
55
56
|
|
|
56
57
|
return {
|
|
57
58
|
name: 'mcp',
|
|
@@ -63,6 +64,19 @@ function mcpPlugin(options = {}) {
|
|
|
63
64
|
registerTool: (tool) => serverInstance && serverInstance.registerTool(tool),
|
|
64
65
|
registerResource: (res) => serverInstance && serverInstance.registerResource(res),
|
|
65
66
|
registerPrompt: (prompt) => serverInstance && serverInstance.registerPrompt(prompt),
|
|
67
|
+
closeTransport: () => sseTransport && sseTransport.close && sseTransport.close(),
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
dispose() {
|
|
71
|
+
if (sseTransport && typeof sseTransport.close === 'function') {
|
|
72
|
+
sseTransport.close();
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
shutdown() {
|
|
77
|
+
if (sseTransport && typeof sseTransport.close === 'function') {
|
|
78
|
+
sseTransport.close();
|
|
79
|
+
}
|
|
66
80
|
},
|
|
67
81
|
|
|
68
82
|
register(ctx) {
|
|
@@ -180,12 +194,20 @@ function mcpPlugin(options = {}) {
|
|
|
180
194
|
const authMiddleware =
|
|
181
195
|
auth.token || auth.localhostOnly || auth.verify ? createMcpAuthMiddleware(auth) : null;
|
|
182
196
|
|
|
183
|
-
mountSseTransport({
|
|
197
|
+
sseTransport = mountSseTransport({
|
|
184
198
|
app,
|
|
185
199
|
server: serverInstance,
|
|
186
200
|
path,
|
|
187
201
|
authMiddleware,
|
|
188
202
|
});
|
|
203
|
+
|
|
204
|
+
if (typeof ctx.onDispose === 'function') {
|
|
205
|
+
ctx.onDispose(() => {
|
|
206
|
+
if (sseTransport && typeof sseTransport.close === 'function') {
|
|
207
|
+
sseTransport.close();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
189
211
|
},
|
|
190
212
|
};
|
|
191
213
|
}
|
|
@@ -150,7 +150,16 @@ function mountSseTransport(options) {
|
|
|
150
150
|
}
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
const close = () => {
|
|
154
|
+
for (const [id, session] of sessions.entries()) {
|
|
155
|
+
try {
|
|
156
|
+
session.res.end();
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
sessions.clear();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return { sessions, close };
|
|
154
163
|
}
|
|
155
164
|
|
|
156
165
|
module.exports = {
|
|
@@ -8,12 +8,13 @@ function generateOrmCacheAdminComponent() {
|
|
|
8
8
|
(function() {
|
|
9
9
|
|
|
10
10
|
function ormCacheApi(method, path, body) {
|
|
11
|
-
var opts = { method: method, credentials: 'same-origin' };
|
|
11
|
+
var opts = { method: method, credentials: 'same-origin', timeout: 30000 };
|
|
12
12
|
if (body !== undefined) {
|
|
13
13
|
opts.headers = { 'Content-Type': 'application/json' };
|
|
14
14
|
opts.body = JSON.stringify(body);
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
var fetchFn = (typeof window !== 'undefined' && window.Admin && window.Admin.fetch) ? window.Admin.fetch : fetch;
|
|
17
|
+
return fetchFn('/_admin/api/orm-cache' + path, opts).then(function(r) {
|
|
17
18
|
return r.json().then(function(j) {
|
|
18
19
|
if (!r.ok) throw new Error(j.error || r.statusText);
|
|
19
20
|
return j;
|
package/plugins/polar/src/api.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { fetchWithTimeout } = require('../../../core/utils/fetch');
|
|
4
|
+
|
|
3
5
|
function getPolarApiBase(sandbox) {
|
|
4
6
|
return sandbox ? 'https://sandbox-api.polar.sh/v1' : 'https://api.polar.sh/v1';
|
|
5
7
|
}
|
|
@@ -11,14 +13,17 @@ function getPolarApiBase(sandbox) {
|
|
|
11
13
|
* @param {Object} [opts.body]
|
|
12
14
|
* @param {string} opts.accessToken
|
|
13
15
|
* @param {boolean} [opts.sandbox=false]
|
|
16
|
+
* @param {number} [opts.timeoutMs=15000]
|
|
17
|
+
* @param {AbortSignal} [opts.signal]
|
|
14
18
|
*/
|
|
15
|
-
async function polarApiRequest(path, { method = 'GET', body, accessToken, sandbox = false } = {}) {
|
|
19
|
+
async function polarApiRequest(path, { method = 'GET', body, accessToken, sandbox = false, timeoutMs = 15000, signal } = {}) {
|
|
16
20
|
if (!accessToken) {
|
|
17
21
|
throw new Error('POLAR_ACCESS_TOKEN is not configured');
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
21
|
-
|
|
25
|
+
|
|
26
|
+
const res = await fetchWithTimeout(`${getPolarApiBase(sandbox)}${normalizedPath}`, {
|
|
22
27
|
method,
|
|
23
28
|
headers: {
|
|
24
29
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -26,6 +31,8 @@ async function polarApiRequest(path, { method = 'GET', body, accessToken, sandbo
|
|
|
26
31
|
Accept: 'application/json',
|
|
27
32
|
},
|
|
28
33
|
body: body ? JSON.stringify(body) : undefined,
|
|
34
|
+
timeout: timeoutMs,
|
|
35
|
+
signal,
|
|
29
36
|
});
|
|
30
37
|
|
|
31
38
|
const raw = await res.text();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
|
+
const { safeCompare } = require('../../../core/utils/crypto');
|
|
4
5
|
const { syncPolarBillingForUser, applyPolarProSubscription } = require('./sync');
|
|
5
6
|
const {
|
|
6
7
|
collectPolarMetadata,
|
|
@@ -46,14 +47,8 @@ function verifyPolarWebhook(rawPayload, headers, secret) {
|
|
|
46
47
|
|
|
47
48
|
for (const candidate of candidates) {
|
|
48
49
|
for (const exp of expectedList) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const b = Buffer.from(exp);
|
|
52
|
-
if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
} catch {
|
|
56
|
-
// continue
|
|
50
|
+
if (safeCompare(candidate, exp)) {
|
|
51
|
+
return true;
|
|
57
52
|
}
|
|
58
53
|
}
|
|
59
54
|
}
|
|
@@ -72,13 +72,7 @@ function recaptchaV3ExecuteScript(opts = {}) {
|
|
|
72
72
|
return hidden + script;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
return String(s)
|
|
77
|
-
.replace(/&/g, '&')
|
|
78
|
-
.replace(/"/g, '"')
|
|
79
|
-
.replace(/</g, '<')
|
|
80
|
-
.replace(/>/g, '>');
|
|
81
|
-
}
|
|
75
|
+
const { escapeHtml } = require('../../core/utils/html');
|
|
82
76
|
|
|
83
77
|
function escapeJsInline(s) {
|
|
84
78
|
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* @module plugins/recaptcha/verify
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const { fetchWithTimeout } = require('../../core/utils/fetch');
|
|
7
|
+
const { getClientIp } = require('../../core/utils/ip');
|
|
8
|
+
|
|
6
9
|
const SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -20,7 +23,8 @@ const SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
|
|
20
23
|
* @param {VerifyOptions} options
|
|
21
24
|
* @returns {Promise<{ success: boolean, score?: number, action?: string, hostname?: string, challenge_ts?: string, errorCodes?: string[], raw: object }>}
|
|
22
25
|
*/
|
|
23
|
-
async function verifyRecaptcha(options) {
|
|
26
|
+
async function verifyRecaptcha(options = {}) {
|
|
27
|
+
const opts = options || {};
|
|
24
28
|
const {
|
|
25
29
|
secret,
|
|
26
30
|
token,
|
|
@@ -28,7 +32,8 @@ async function verifyRecaptcha(options) {
|
|
|
28
32
|
version = 'v2',
|
|
29
33
|
minScore = 0.5,
|
|
30
34
|
expectedAction,
|
|
31
|
-
|
|
35
|
+
timeoutMs = 10000,
|
|
36
|
+
} = opts;
|
|
32
37
|
|
|
33
38
|
if (!secret || typeof secret !== 'string') {
|
|
34
39
|
throw new Error('recaptcha: secret is required');
|
|
@@ -49,13 +54,24 @@ async function verifyRecaptcha(options) {
|
|
|
49
54
|
body.set('remoteip', remoteIp);
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
let res;
|
|
58
|
+
try {
|
|
59
|
+
res = await fetchWithTimeout(SITEVERIFY_URL, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
63
|
+
},
|
|
64
|
+
body: body.toString(),
|
|
65
|
+
timeout: timeoutMs,
|
|
66
|
+
});
|
|
67
|
+
} catch (netErr) {
|
|
68
|
+
const isTimeout = netErr.name === 'AbortError' || netErr.name === 'TimeoutError' || netErr.code === 'ETIMEDOUT';
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
errorCodes: [isTimeout ? 'timeout' : 'network-error'],
|
|
72
|
+
raw: {},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
59
75
|
|
|
60
76
|
if (!res.ok) {
|
|
61
77
|
return {
|
|
@@ -66,8 +82,17 @@ async function verifyRecaptcha(options) {
|
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
/** @type {Record<string, unknown>} */
|
|
69
|
-
|
|
70
|
-
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = await res.json();
|
|
88
|
+
} catch (jsonErr) {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
errorCodes: ['invalid-json-response'],
|
|
92
|
+
raw: {},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const googleSuccess = raw && raw.success === true;
|
|
71
96
|
|
|
72
97
|
if (!googleSuccess) {
|
|
73
98
|
return {
|
|
@@ -129,18 +154,7 @@ async function verifyRecaptcha(options) {
|
|
|
129
154
|
* @returns {string|undefined}
|
|
130
155
|
*/
|
|
131
156
|
function getRemoteIp(req) {
|
|
132
|
-
|
|
133
|
-
const xf = req.headers && req.headers['x-forwarded-for'];
|
|
134
|
-
if (typeof xf === 'string' && xf.length) {
|
|
135
|
-
return xf.split(',')[0].trim();
|
|
136
|
-
}
|
|
137
|
-
if (req.ip) {
|
|
138
|
-
return req.ip;
|
|
139
|
-
}
|
|
140
|
-
if (req.socket && req.socket.remoteAddress) {
|
|
141
|
-
return req.socket.remoteAddress;
|
|
142
|
-
}
|
|
143
|
-
return undefined;
|
|
157
|
+
return getClientIp(req, undefined);
|
|
144
158
|
}
|
|
145
159
|
|
|
146
160
|
module.exports = {
|
|
@@ -151,6 +151,7 @@ function methodMatches(compiled, method) {
|
|
|
151
151
|
* @returns {{ name: string, version: string, description: string, register: Function }}
|
|
152
152
|
*/
|
|
153
153
|
function redirectPlugin(options = {}) {
|
|
154
|
+
const opts = options || {};
|
|
154
155
|
const {
|
|
155
156
|
rules = [],
|
|
156
157
|
defaultStatus = 302,
|
|
@@ -158,7 +159,7 @@ function redirectPlugin(options = {}) {
|
|
|
158
159
|
allowExternal = false,
|
|
159
160
|
trailingSlash = false,
|
|
160
161
|
defaultMethods = ['GET', 'HEAD'],
|
|
161
|
-
} =
|
|
162
|
+
} = opts;
|
|
162
163
|
|
|
163
164
|
const pluginOpts = {
|
|
164
165
|
defaultStatus: REDIRECT_STATUSES.has(Number(defaultStatus)) ? Number(defaultStatus) : 302,
|
|
@@ -169,16 +170,63 @@ function redirectPlugin(options = {}) {
|
|
|
169
170
|
};
|
|
170
171
|
|
|
171
172
|
const compiled = [];
|
|
172
|
-
|
|
173
|
+
const stringRulesMap = new Map();
|
|
174
|
+
let hasRegexRules = false;
|
|
175
|
+
|
|
176
|
+
const ruleList = Array.isArray(rules) ? rules : [];
|
|
177
|
+
for (const rule of ruleList) {
|
|
173
178
|
const c = compileRule(rule, pluginOpts);
|
|
174
|
-
if (c)
|
|
179
|
+
if (!c) continue;
|
|
180
|
+
compiled.push(c);
|
|
181
|
+
if (c.kind === 'string') {
|
|
182
|
+
const keys = [c.fromNorm];
|
|
183
|
+
if (!c.trailing) {
|
|
184
|
+
const alt = c.fromRaw.replace(/\/$/, '') || '/';
|
|
185
|
+
if (!keys.includes(alt)) keys.push(alt);
|
|
186
|
+
}
|
|
187
|
+
for (const k of keys) {
|
|
188
|
+
let list = stringRulesMap.get(k);
|
|
189
|
+
if (!list) {
|
|
190
|
+
list = [];
|
|
191
|
+
stringRulesMap.set(k, list);
|
|
192
|
+
}
|
|
193
|
+
list.push(c);
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
hasRegexRules = true;
|
|
197
|
+
}
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
function redirectMiddleware(req, res, next) {
|
|
201
|
+
if (!req) return next();
|
|
178
202
|
const rawPath = req.path || '/';
|
|
179
203
|
const pathForMatch = normalizePathForTrailingSlash(pluginOpts.trailingSlash, rawPath);
|
|
180
204
|
const method = req.method || 'GET';
|
|
181
205
|
|
|
206
|
+
// Fast O(1) path when all configured rules are string based
|
|
207
|
+
if (!hasRegexRules) {
|
|
208
|
+
const candidates = stringRulesMap.get(pathForMatch) || (!pluginOpts.trailingSlash ? stringRulesMap.get(rawPath.replace(/\/$/, '') || '/') : null);
|
|
209
|
+
if (candidates) {
|
|
210
|
+
for (const c of candidates) {
|
|
211
|
+
if (!methodMatches(c, method)) continue;
|
|
212
|
+
if (!pathMatches(c, pathForMatch, rawPath)) continue;
|
|
213
|
+
|
|
214
|
+
let location = c.to;
|
|
215
|
+
if (pluginOpts.preserveQuery && !location.includes('?')) {
|
|
216
|
+
const full = req.originalUrl || req.url || '';
|
|
217
|
+
const qi = full.indexOf('?');
|
|
218
|
+
if (qi !== -1) {
|
|
219
|
+
location += full.slice(qi);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
res.redirect(c.status, location);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return next();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Linear path when regex rules are present to preserve definition order
|
|
182
230
|
for (const c of compiled) {
|
|
183
231
|
if (!methodMatches(c, method)) continue;
|
|
184
232
|
if (!pathMatches(c, pathForMatch, rawPath)) continue;
|