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/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",
|
|
@@ -176,6 +176,7 @@
|
|
|
176
176
|
"dotenv": "^16.3.1",
|
|
177
177
|
"express-rate-limit": "^8.4.1",
|
|
178
178
|
"release-it": "^20.2.1",
|
|
179
|
+
"softscope": "^0.3.2",
|
|
179
180
|
"supertest": "^6.3.4",
|
|
180
181
|
"typescript": "~5.6.3",
|
|
181
182
|
"vitest": "^4.1.11",
|
|
@@ -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,16 +99,14 @@ 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
|
-
knex(tableName).insert(row).catch(
|
|
99
|
-
console.warn('[audit-log] Failed to insert audit row:', err.message);
|
|
100
|
-
});
|
|
107
|
+
knex(tableName).insert(row).catch(logAuditError);
|
|
101
108
|
} catch (e) {
|
|
102
|
-
|
|
109
|
+
logAuditError(e, 'finish handler error');
|
|
103
110
|
}
|
|
104
111
|
});
|
|
105
112
|
|
|
@@ -107,9 +114,46 @@ function createAuditMiddleware(options) {
|
|
|
107
114
|
};
|
|
108
115
|
}
|
|
109
116
|
|
|
117
|
+
let lastAuditWarnTime = 0;
|
|
118
|
+
let suppressedAuditErrors = 0;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Format and sanitize DB errors to prevent leaking raw SQL queries
|
|
122
|
+
* @param {Error|Object|string} e
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
function formatDbError(e) {
|
|
126
|
+
if (!e) return 'Unknown error';
|
|
127
|
+
let msg;
|
|
128
|
+
if (typeof e.message === 'string' && e.message.includes(' - ')) {
|
|
129
|
+
msg = e.message.split(' - ').pop().trim();
|
|
130
|
+
} else {
|
|
131
|
+
msg = String(e.code || e.message || 'Database error');
|
|
132
|
+
}
|
|
133
|
+
return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function logAuditError(err, prefix = 'Failed to insert audit row') {
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
const formatted = formatDbError(err);
|
|
139
|
+
if (lastAuditWarnTime === 0 || now - lastAuditWarnTime >= 60000) {
|
|
140
|
+
const elapsedSec = lastAuditWarnTime > 0 ? Math.round((now - lastAuditWarnTime) / 1000) : 0;
|
|
141
|
+
if (suppressedAuditErrors > 0) {
|
|
142
|
+
console.warn(`[audit-log] ${prefix} (suppressed ${suppressedAuditErrors} occurrences in last ${elapsedSec}s): ${formatted}`);
|
|
143
|
+
} else {
|
|
144
|
+
console.warn(`[audit-log] ${prefix}: ${formatted}`);
|
|
145
|
+
}
|
|
146
|
+
lastAuditWarnTime = now;
|
|
147
|
+
suppressedAuditErrors = 0;
|
|
148
|
+
} else {
|
|
149
|
+
suppressedAuditErrors++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
110
153
|
module.exports = {
|
|
111
154
|
createAuditMiddleware,
|
|
112
155
|
stringifyId,
|
|
113
156
|
extractResourceIdFromJsonBody,
|
|
114
157
|
buildMetadata,
|
|
158
|
+
formatDbError,
|
|
115
159
|
};
|
|
@@ -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;
|
package/plugins/email/service.js
CHANGED
|
@@ -30,6 +30,37 @@ function createEmailService(options) {
|
|
|
30
30
|
console.warn('[email]', err.message);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
let lastEmailLogWarnTime = 0;
|
|
34
|
+
let suppressedEmailLogErrors = 0;
|
|
35
|
+
|
|
36
|
+
function formatDbError(e) {
|
|
37
|
+
if (!e) return 'Unknown error';
|
|
38
|
+
let msg;
|
|
39
|
+
if (typeof e.message === 'string' && e.message.includes(' - ')) {
|
|
40
|
+
msg = e.message.split(' - ').pop().trim();
|
|
41
|
+
} else {
|
|
42
|
+
msg = String(e.code || e.message || 'Database error');
|
|
43
|
+
}
|
|
44
|
+
return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function logEmailError(err) {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
const formatted = formatDbError(err);
|
|
50
|
+
if (lastEmailLogWarnTime === 0 || now - lastEmailLogWarnTime >= 60000) {
|
|
51
|
+
const elapsedSec = lastEmailLogWarnTime > 0 ? Math.round((now - lastEmailLogWarnTime) / 1000) : 0;
|
|
52
|
+
if (suppressedEmailLogErrors > 0) {
|
|
53
|
+
console.warn(`[email] Failed to write email log (suppressed ${suppressedEmailLogErrors} occurrences in last ${elapsedSec}s): ${formatted}`);
|
|
54
|
+
} else {
|
|
55
|
+
console.warn('[email] Failed to write email log:', formatted);
|
|
56
|
+
}
|
|
57
|
+
lastEmailLogWarnTime = now;
|
|
58
|
+
suppressedEmailLogErrors = 0;
|
|
59
|
+
} else {
|
|
60
|
+
suppressedEmailLogErrors++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
33
64
|
/**
|
|
34
65
|
* @param {Object} row
|
|
35
66
|
*/
|
|
@@ -49,7 +80,7 @@ function createEmailService(options) {
|
|
|
49
80
|
created_at: new Date(),
|
|
50
81
|
});
|
|
51
82
|
} catch (err) {
|
|
52
|
-
|
|
83
|
+
logEmailError(err);
|
|
53
84
|
}
|
|
54
85
|
}
|
|
55
86
|
|