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
|
@@ -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/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 {
|
|
@@ -3787,7 +3797,7 @@ export class SessionManager {
|
|
|
3787
3797
|
}
|
|
3788
3798
|
|
|
3789
3799
|
export function parseUserAgent(userAgent?: string): DeviceInfo;
|
|
3790
|
-
export function getClientIp(req?: any): string;
|
|
3800
|
+
export function getClientIp<T = string>(req?: any, fallback?: T): string | T;
|
|
3791
3801
|
export function createUserSessionsTable(knex: any): Promise<void>;
|
|
3792
3802
|
export function dropUserSessionsTable(knex: any): Promise<void>;
|
|
3793
3803
|
export function createKnexSessionTracker(db: any, options?: { tableName?: string }): SessionTracker;
|
package/index.js
CHANGED
|
@@ -219,6 +219,12 @@ module.exports = {
|
|
|
219
219
|
defineSignal: require('./core/events').defineSignal,
|
|
220
220
|
defineListener: require('./core/events').defineListener,
|
|
221
221
|
loadListeners: require('./core/events').loadListeners,
|
|
222
|
+
|
|
223
|
+
// Shared Fetch, Crypto & HTML Utilities
|
|
224
|
+
fetchWithTimeout: require('./core/utils/fetch').fetchWithTimeout,
|
|
225
|
+
safeCompare: require('./core/utils/crypto').safeCompare,
|
|
226
|
+
escapeHtml: require('./core/utils/html').escapeHtml,
|
|
227
|
+
unescapeHtml: require('./core/utils/html').unescapeHtml,
|
|
222
228
|
};
|
|
223
229
|
|
|
224
230
|
// Define lazy getters for plugins, ORM, and heavy core subsystems
|
|
@@ -319,6 +325,10 @@ Object.defineProperties(module.exports, {
|
|
|
319
325
|
generateQrDataUri: { get: () => require('./core/auth/qrcode').generateQrDataUri, enumerable: true, configurable: true },
|
|
320
326
|
createAuthMiddleware: { get: () => require('./core/auth').createAuthMiddleware, enumerable: true, configurable: true },
|
|
321
327
|
setupAuthMiddleware: { get: () => require('./core/auth').setupAuthMiddleware, enumerable: true, configurable: true },
|
|
328
|
+
getClientIp: { get: () => require('./core/utils/ip').getClientIp, enumerable: true, configurable: true },
|
|
329
|
+
parseUserAgent: { get: () => require('./core/auth').parseUserAgent, enumerable: true, configurable: true },
|
|
330
|
+
sessions: { get: () => require('./core/auth').sessions, enumerable: true, configurable: true },
|
|
331
|
+
SessionManager: { get: () => require('./core/auth').SessionManager, enumerable: true, configurable: true },
|
|
322
332
|
|
|
323
333
|
// Validation
|
|
324
334
|
z: { get: () => require('./core/validation').z, enumerable: true, configurable: true },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.104",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -115,13 +115,21 @@ async function login(adminUserRepo, email, password, compare) {
|
|
|
115
115
|
*/
|
|
116
116
|
async function logout(req, res) {
|
|
117
117
|
return new Promise((resolve, reject) => {
|
|
118
|
-
req.session
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
if (!req || !req.session) {
|
|
119
|
+
return resolve();
|
|
120
|
+
}
|
|
121
|
+
if (typeof req.session.destroy === 'function') {
|
|
122
|
+
req.session.destroy((err) => {
|
|
123
|
+
if (err) {
|
|
124
|
+
reject(err);
|
|
125
|
+
} else {
|
|
126
|
+
resolve();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
delete req.session.adminUser;
|
|
131
|
+
resolve();
|
|
132
|
+
}
|
|
125
133
|
});
|
|
126
134
|
}
|
|
127
135
|
|
|
@@ -1,14 +1,82 @@
|
|
|
1
1
|
|
|
2
|
+
/**
|
|
3
|
+
* Shared client-side fetch wrapper with AbortController, timeout, and signal composition.
|
|
4
|
+
* @param {string} url
|
|
5
|
+
* @param {RequestInit & { timeout?: number }} [options]
|
|
6
|
+
* @returns {Promise<Response>}
|
|
7
|
+
*/
|
|
8
|
+
async function fetchWithTimeout(url, options = {}) {
|
|
9
|
+
const { timeout = 30000, signal, ...fetchOptions } = options;
|
|
10
|
+
|
|
11
|
+
if (signal && signal.aborted) {
|
|
12
|
+
const reason = (signal.reason !== undefined && signal.reason !== null)
|
|
13
|
+
? signal.reason
|
|
14
|
+
: new Error('This operation was aborted');
|
|
15
|
+
throw reason;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (typeof AbortController === 'undefined') {
|
|
19
|
+
return fetch(url, { ...fetchOptions, signal });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
let timer = null;
|
|
24
|
+
let callerSignalListener = null;
|
|
25
|
+
|
|
26
|
+
if (typeof timeout === 'number' && timeout > 0 && typeof setTimeout !== 'undefined') {
|
|
27
|
+
timer = setTimeout(() => {
|
|
28
|
+
const err = new Error('Request timed out after ' + timeout + 'ms');
|
|
29
|
+
err.name = 'TimeoutError';
|
|
30
|
+
err.code = 'ETIMEDOUT';
|
|
31
|
+
controller.abort(err);
|
|
32
|
+
}, timeout);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (signal) {
|
|
36
|
+
callerSignalListener = () => {
|
|
37
|
+
controller.abort(signal.reason);
|
|
38
|
+
};
|
|
39
|
+
signal.addEventListener('abort', callerSignalListener, { once: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return await fetch(url, {
|
|
44
|
+
...fetchOptions,
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (controller.signal.aborted && controller.signal.reason) {
|
|
49
|
+
const reason = controller.signal.reason;
|
|
50
|
+
if (reason instanceof Error && (reason.name === 'TimeoutError' || reason.code === 'ETIMEDOUT')) {
|
|
51
|
+
throw reason;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (err && err.name === 'AbortError' && controller.signal.aborted && controller.signal.reason) {
|
|
55
|
+
throw controller.signal.reason;
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
} finally {
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
if (signal && callerSignalListener) {
|
|
61
|
+
signal.removeEventListener('abort', callerSignalListener);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
2
66
|
// API helper
|
|
3
67
|
const api = {
|
|
68
|
+
fetch: fetchWithTimeout,
|
|
4
69
|
async request(path, options = {}) {
|
|
5
70
|
const adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
6
71
|
const url = adminPath + '/api' + path;
|
|
7
|
-
const
|
|
8
|
-
|
|
72
|
+
const { timeout = 30000, signal, ...fetchOpts } = options;
|
|
73
|
+
const response = await fetchWithTimeout(url, {
|
|
74
|
+
...fetchOpts,
|
|
75
|
+
timeout,
|
|
76
|
+
signal,
|
|
9
77
|
headers: {
|
|
10
78
|
'Content-Type': 'application/json',
|
|
11
|
-
...
|
|
79
|
+
...fetchOpts.headers,
|
|
12
80
|
},
|
|
13
81
|
credentials: 'include',
|
|
14
82
|
});
|
|
@@ -28,20 +96,24 @@ const api = {
|
|
|
28
96
|
return response.json();
|
|
29
97
|
},
|
|
30
98
|
|
|
31
|
-
get(path) { return this.request(path, { method: 'GET' }); },
|
|
32
|
-
post(path, data) { return this.request(path, { method: 'POST', body: JSON.stringify(data) }); },
|
|
33
|
-
put(path, data) { return this.request(path, { method: 'PUT', body: JSON.stringify(data) }); },
|
|
34
|
-
delete(path) { return this.request(path, { method: 'DELETE' }); },
|
|
99
|
+
get(path, options) { return this.request(path, { ...options, method: 'GET' }); },
|
|
100
|
+
post(path, data, options) { return this.request(path, { ...options, method: 'POST', body: JSON.stringify(data) }); },
|
|
101
|
+
put(path, data, options) { return this.request(path, { ...options, method: 'PUT', body: JSON.stringify(data) }); },
|
|
102
|
+
delete(path, options) { return this.request(path, { ...options, method: 'DELETE' }); },
|
|
35
103
|
};
|
|
36
104
|
|
|
37
105
|
/** POST /data-exchange/export/:model — validates JSON errors vs .xlsx blob */
|
|
38
|
-
async function downloadDataExchangeXlsx(modelName, payload) {
|
|
106
|
+
async function downloadDataExchangeXlsx(modelName, payload, options = {}) {
|
|
39
107
|
const adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
40
|
-
const
|
|
108
|
+
const { timeout = 60000, signal, headers = {}, ...restOpts } = options;
|
|
109
|
+
const res = await fetchWithTimeout(adminPath + '/api/data-exchange/export/' + modelName, {
|
|
41
110
|
method: 'POST',
|
|
42
111
|
credentials: 'include',
|
|
43
|
-
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
44
113
|
body: JSON.stringify(payload),
|
|
114
|
+
timeout,
|
|
115
|
+
signal,
|
|
116
|
+
...restOpts,
|
|
45
117
|
});
|
|
46
118
|
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
47
119
|
if (!res.ok || ct.indexOf('spreadsheet') === -1) {
|
|
@@ -572,7 +572,8 @@ async function handleAdminFileUpload(file, onChange, meta, state) {
|
|
|
572
572
|
var fd = new FormData();
|
|
573
573
|
fd.append('file', file);
|
|
574
574
|
try {
|
|
575
|
-
var
|
|
575
|
+
var fetchFn = (typeof fetchWithTimeout === 'function' ? fetchWithTimeout : fetch);
|
|
576
|
+
var res = await fetchFn(uploadUrl, { method: 'POST', body: fd, credentials: 'include', timeout: 60000 });
|
|
576
577
|
var data = {};
|
|
577
578
|
try { data = await res.json(); } catch (e2) { data = {}; }
|
|
578
579
|
if (!res.ok) {
|
|
@@ -619,7 +620,8 @@ async function handleAdminMultipleFilesUpload(files, onChange, meta, state, curr
|
|
|
619
620
|
var fd = new FormData();
|
|
620
621
|
fd.append('file', file);
|
|
621
622
|
try {
|
|
622
|
-
var
|
|
623
|
+
var fetchFn = (typeof fetchWithTimeout === 'function' ? fetchWithTimeout : fetch);
|
|
624
|
+
var res = await fetchFn(uploadUrl, { method: 'POST', body: fd, credentials: 'include', timeout: 60000 });
|
|
623
625
|
var data = {};
|
|
624
626
|
try { data = await res.json(); } catch (_) { data = {}; }
|
|
625
627
|
if (res.ok && (data.url || data.publicUrl)) {
|
|
@@ -316,10 +316,11 @@ const RecordList = {
|
|
|
316
316
|
const fd = new FormData();
|
|
317
317
|
fd.append('file', file);
|
|
318
318
|
try {
|
|
319
|
-
const
|
|
319
|
+
const fetchFn = (typeof fetchWithTimeout === 'function' ? fetchWithTimeout : fetch);
|
|
320
|
+
const res = await fetchFn(
|
|
320
321
|
adminPath + '/api/data-exchange/import/' + modelName +
|
|
321
322
|
'?mode=' + encodeURIComponent(mode) + '&upsertKey=' + encodeURIComponent(upsertKey),
|
|
322
|
-
{ method: 'POST', body: fd, credentials: 'include' }
|
|
323
|
+
{ method: 'POST', body: fd, credentials: 'include', timeout: 60000 }
|
|
323
324
|
);
|
|
324
325
|
const body = await res.json().catch(function () { return ({}); });
|
|
325
326
|
if (!res.ok) {
|
|
@@ -8,6 +8,8 @@ window.__ADMIN_COMPONENTS__ = {
|
|
|
8
8
|
RecordForm,
|
|
9
9
|
api,
|
|
10
10
|
state,
|
|
11
|
+
fetch: typeof fetchWithTimeout !== 'undefined' ? fetchWithTimeout : undefined,
|
|
12
|
+
fetchWithTimeout: typeof fetchWithTimeout !== 'undefined' ? fetchWithTimeout : undefined,
|
|
11
13
|
notify: typeof window !== 'undefined' ? window.notify : undefined,
|
|
12
14
|
confirmModal: typeof window !== 'undefined' ? window.confirmModal : undefined,
|
|
13
15
|
toastState: typeof window !== 'undefined' ? window.toastState : undefined,
|
|
@@ -22,6 +24,10 @@ if (typeof window !== 'undefined') {
|
|
|
22
24
|
window.Admin = window.Admin || {};
|
|
23
25
|
window.Admin.api = api;
|
|
24
26
|
window.Admin.state = state;
|
|
27
|
+
if (typeof fetchWithTimeout !== 'undefined') {
|
|
28
|
+
window.Admin.fetch = fetchWithTimeout;
|
|
29
|
+
window.Admin.fetchWithTimeout = fetchWithTimeout;
|
|
30
|
+
}
|
|
25
31
|
if (typeof window.AdminUI !== 'undefined') {
|
|
26
32
|
window.Admin.ui = window.AdminUI;
|
|
27
33
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const https = require('https');
|
|
9
9
|
const { sanitizeForOutput } = require('../../../core/orm/utils');
|
|
10
|
+
const { buildFilteredQuery } = require('../../../core/orm/query-filters');
|
|
10
11
|
|
|
11
12
|
function getWebspressoVersion() {
|
|
12
13
|
try {
|
|
@@ -115,82 +116,7 @@ function formatBytes(bytes) {
|
|
|
115
116
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
/**
|
|
119
|
-
* Build query with filters applied
|
|
120
|
-
* @param {Object} repo - Repository instance
|
|
121
|
-
* @param {Object} filters - Filter object from frontend
|
|
122
|
-
* @param {Object} [options] - Options
|
|
123
|
-
* @param {boolean} [options.onlyTrashed] - For soft delete: only trashed records
|
|
124
|
-
* @returns {Object} Query builder with filters applied
|
|
125
|
-
*/
|
|
126
|
-
function buildFilteredQuery(repo, filters, options = {}) {
|
|
127
|
-
let query = repo.query();
|
|
128
|
-
if (options.onlyTrashed) {
|
|
129
|
-
query = query.onlyTrashed();
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (!filters || Object.keys(filters).length === 0) {
|
|
133
|
-
return query;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
for (const [column, filter] of Object.entries(filters)) {
|
|
137
|
-
if (!filter) continue;
|
|
138
|
-
|
|
139
|
-
const op = filter.op || filter.operator || 'contains';
|
|
140
119
|
|
|
141
|
-
if (op === 'is_null') {
|
|
142
|
-
query = query.whereNull(column);
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
if (op === 'is_not_null') {
|
|
146
|
-
query = query.whereNotNull(column);
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (filter.value === '' && !filter.from && !filter.to) continue;
|
|
151
|
-
|
|
152
|
-
switch (op) {
|
|
153
|
-
case 'contains':
|
|
154
|
-
query = query.where(column, 'like', `%${filter.value}%`);
|
|
155
|
-
break;
|
|
156
|
-
case 'equals':
|
|
157
|
-
query = query.where(column, '=', filter.value);
|
|
158
|
-
break;
|
|
159
|
-
case 'starts_with':
|
|
160
|
-
query = query.where(column, 'like', `${filter.value}%`);
|
|
161
|
-
break;
|
|
162
|
-
case 'ends_with':
|
|
163
|
-
query = query.where(column, 'like', `%${filter.value}`);
|
|
164
|
-
break;
|
|
165
|
-
case 'eq':
|
|
166
|
-
query = query.where(column, '=', filter.value);
|
|
167
|
-
break;
|
|
168
|
-
case 'gt':
|
|
169
|
-
query = query.where(column, '>', filter.value);
|
|
170
|
-
break;
|
|
171
|
-
case 'gte':
|
|
172
|
-
query = query.where(column, '>=', filter.value);
|
|
173
|
-
break;
|
|
174
|
-
case 'lt':
|
|
175
|
-
query = query.where(column, '<', filter.value);
|
|
176
|
-
break;
|
|
177
|
-
case 'lte':
|
|
178
|
-
query = query.where(column, '<=', filter.value);
|
|
179
|
-
break;
|
|
180
|
-
case 'between':
|
|
181
|
-
if (filter.from) query = query.where(column, '>=', filter.from);
|
|
182
|
-
if (filter.to) query = query.where(column, '<=', filter.to);
|
|
183
|
-
break;
|
|
184
|
-
case 'in':
|
|
185
|
-
if (Array.isArray(filter.value) && filter.value.length > 0) {
|
|
186
|
-
query = query.whereIn(column, filter.value);
|
|
187
|
-
}
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return query;
|
|
193
|
-
}
|
|
194
120
|
|
|
195
121
|
/**
|
|
196
122
|
* Get all record IDs matching filters (for selectAll mode)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { getClientIp } = require('../../../core/utils/ip');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* In-memory brute-force rate limiter for admin login.
|
|
5
7
|
* Zero external dependencies.
|
|
@@ -20,19 +22,7 @@ class LoginRateLimiter {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
getClientIp(req) {
|
|
23
|
-
|
|
24
|
-
if (req.ip) return String(req.ip);
|
|
25
|
-
if (req.headers && req.headers['x-forwarded-for']) {
|
|
26
|
-
const forwarded = String(req.headers['x-forwarded-for']).split(',')[0].trim();
|
|
27
|
-
if (forwarded) return forwarded;
|
|
28
|
-
}
|
|
29
|
-
if (req.connection && req.connection.remoteAddress) {
|
|
30
|
-
return String(req.connection.remoteAddress);
|
|
31
|
-
}
|
|
32
|
-
if (req.socket && req.socket.remoteAddress) {
|
|
33
|
-
return String(req.socket.remoteAddress);
|
|
34
|
-
}
|
|
35
|
-
return '127.0.0.1';
|
|
25
|
+
return getClientIp(req);
|
|
36
26
|
}
|
|
37
27
|
|
|
38
28
|
isBlocked(req) {
|
|
@@ -111,6 +101,18 @@ class LoginRateLimiter {
|
|
|
111
101
|
this.attempts.delete(ip);
|
|
112
102
|
}
|
|
113
103
|
}
|
|
104
|
+
|
|
105
|
+
// Hard ceiling to prevent unbounded memory growth under distributed IP attacks
|
|
106
|
+
const MAX_ENTRIES = 5000;
|
|
107
|
+
if (this.attempts.size > MAX_ENTRIES) {
|
|
108
|
+
const excess = this.attempts.size - MAX_ENTRIES;
|
|
109
|
+
let count = 0;
|
|
110
|
+
for (const ip of this.attempts.keys()) {
|
|
111
|
+
this.attempts.delete(ip);
|
|
112
|
+
count++;
|
|
113
|
+
if (count >= excess) break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
118
|
|
|
@@ -8,30 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const crypto = require('crypto');
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
* Compare two strings in constant time to prevent timing attacks.
|
|
13
|
-
* Uses dummy buffer comparison if lengths differ to avoid leaking length via response timing.
|
|
14
|
-
*
|
|
15
|
-
* @param {string} a
|
|
16
|
-
* @param {string} b
|
|
17
|
-
* @returns {boolean}
|
|
18
|
-
*/
|
|
19
|
-
function safeCompare(a, b) {
|
|
20
|
-
if (typeof a !== 'string' || typeof b !== 'string') {
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const bufA = Buffer.from(a, 'utf8');
|
|
25
|
-
const bufB = Buffer.from(b, 'utf8');
|
|
26
|
-
|
|
27
|
-
if (bufA.length !== bufB.length) {
|
|
28
|
-
// Perform dummy timing-safe comparison with itself to preserve constant execution time
|
|
29
|
-
crypto.timingSafeEqual(bufA, bufA);
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return crypto.timingSafeEqual(bufA, bufB);
|
|
34
|
-
}
|
|
11
|
+
const { safeCompare } = require('../../core/utils/crypto');
|
|
35
12
|
|
|
36
13
|
/**
|
|
37
14
|
* Hash a raw API key using SHA-256
|
|
@@ -66,8 +66,18 @@ function createAuditLogHandlers(options) {
|
|
|
66
66
|
.offset((page - 1) * perPage)
|
|
67
67
|
.limit(perPage);
|
|
68
68
|
|
|
69
|
+
const normalizedRows = rows.map((r) => {
|
|
70
|
+
let parsedMetadata = r.metadata;
|
|
71
|
+
if (typeof parsedMetadata === 'string') {
|
|
72
|
+
try {
|
|
73
|
+
parsedMetadata = JSON.parse(parsedMetadata);
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
return { ...r, metadata: parsedMetadata };
|
|
77
|
+
});
|
|
78
|
+
|
|
69
79
|
res.json({
|
|
70
|
-
data:
|
|
80
|
+
data: normalizedRows,
|
|
71
81
|
meta: { page, perPage, total },
|
|
72
82
|
});
|
|
73
83
|
} catch (err) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const { parseAdminModelAudit } = require('./parse');
|
|
7
|
+
const { getClientIp } = require('../../core/utils/ip');
|
|
7
8
|
|
|
8
9
|
function stringifyId(val) {
|
|
9
10
|
if (val === undefined || val === null) {
|
|
@@ -81,6 +82,14 @@ function createAuditMiddleware(options) {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
const metadata = buildMetadata(action, req);
|
|
85
|
+
let metadataVal = metadata || null;
|
|
86
|
+
if (metadataVal && typeof metadataVal === 'object') {
|
|
87
|
+
const clientName = knex?.client?.config?.client || '';
|
|
88
|
+
if (typeof clientName === 'string' && clientName.includes('sqlite')) {
|
|
89
|
+
metadataVal = JSON.stringify(metadataVal);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
84
93
|
const pathStr = (req.originalUrl || req.url || req.path || '').slice(0, 2000);
|
|
85
94
|
const row = {
|
|
86
95
|
actor_id: actor.id != null ? Number(actor.id) : null,
|
|
@@ -90,9 +99,9 @@ function createAuditMiddleware(options) {
|
|
|
90
99
|
resource_id: resourceId,
|
|
91
100
|
http_method: req.method,
|
|
92
101
|
path: pathStr,
|
|
93
|
-
ip:
|
|
102
|
+
ip: getClientIp(req, null),
|
|
94
103
|
user_agent: req.get && req.get('user-agent') ? req.get('user-agent').slice(0, 2000) : null,
|
|
95
|
-
metadata:
|
|
104
|
+
metadata: metadataVal,
|
|
96
105
|
};
|
|
97
106
|
|
|
98
107
|
knex(tableName).insert(row).catch(logAuditError);
|