webspresso 0.0.103 → 0.0.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/commands/dev.js +3 -2
  2. package/bin/utils/orm-map-html.js +2 -8
  3. package/core/auth/jwt.js +2 -4
  4. package/core/auth/oauth.js +38 -14
  5. package/core/auth/totp.js +3 -15
  6. package/core/auth/user-agent.js +1 -21
  7. package/core/content/renderer.js +1 -13
  8. package/core/errors/middleware.js +2 -8
  9. package/core/forms/form-builder.js +1 -9
  10. package/core/orm/index.js +5 -0
  11. package/core/orm/query-filters.js +393 -0
  12. package/core/shutdown/shutdown-manager.js +16 -16
  13. package/core/utils/crypto.js +73 -0
  14. package/core/utils/escape-html.js +7 -0
  15. package/core/utils/fetch.js +120 -0
  16. package/core/utils/get-client-ip.js +6 -0
  17. package/core/utils/html.js +72 -0
  18. package/core/utils/ip.js +86 -0
  19. package/core/utils/safe-compare.js +3 -0
  20. package/index.d.ts +11 -1
  21. package/index.js +10 -0
  22. package/package.json +1 -1
  23. package/plugins/admin-panel/auth.js +15 -7
  24. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  25. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  26. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  27. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  28. package/plugins/admin-panel/core/api-extensions.js +1 -75
  29. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  30. package/plugins/api-key/crypto.js +1 -24
  31. package/plugins/audit-log/api-handlers.js +11 -1
  32. package/plugins/audit-log/middleware.js +11 -2
  33. package/plugins/basic-auth/index.js +2 -26
  34. package/plugins/cors.js +2 -2
  35. package/plugins/csrf/index.js +3 -15
  36. package/plugins/file-manager/admin-component.js +3 -1
  37. package/plugins/file-manager/core.js +34 -4
  38. package/plugins/mcp/auth.js +1 -13
  39. package/plugins/mcp/index.js +23 -1
  40. package/plugins/mcp/transports/sse.js +10 -1
  41. package/plugins/orm-cache-admin/admin-component.js +3 -2
  42. package/plugins/polar/src/api.js +9 -2
  43. package/plugins/polar/src/webhooks.js +3 -8
  44. package/plugins/recaptcha/helpers.js +1 -7
  45. package/plugins/recaptcha/verify.js +37 -23
  46. package/plugins/redirect/index.js +51 -3
  47. package/plugins/rest-resources/index.js +1 -207
  48. package/plugins/schema-explorer/admin-component.js +4 -2
  49. package/plugins/schema-explorer.js +40 -29
  50. package/plugins/seo-checker/index.js +5 -2
  51. package/plugins/site-analytics/tracking.js +19 -16
  52. package/plugins/sitemap.js +18 -15
  53. package/plugins/swagger.js +9 -14
  54. package/plugins/webhooks/crypto.js +1 -18
  55. package/plugins/webhooks/dispatcher.js +6 -1
  56. package/plugins/webhooks/matcher.js +25 -10
  57. package/plugins/webhooks/sender.js +32 -2
  58. package/src/dev-reload/index.js +47 -6
  59. package/src/helpers.js +21 -9
  60. package/src/server.js +2 -2
@@ -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
+ '&': '&amp;',
16
+ '<': '&lt;',
17
+ '>': '&gt;',
18
+ '"': '&quot;',
19
+ "'": '&#39;',
20
+ };
21
+
22
+ const HTML_UNESCAPE_RE = /&(?:amp|lt|gt|quot|#39|#039|#x27);/g;
23
+
24
+ const HTML_UNESCAPES = {
25
+ '&amp;': '&',
26
+ '&lt;': '<',
27
+ '&gt;': '>',
28
+ '&quot;': '"',
29
+ '&#39;': "'",
30
+ '&#039;': "'",
31
+ '&#x27;': "'",
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
+ };
@@ -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
+ };
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('./crypto');
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.103",
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.destroy((err) => {
119
- if (err) {
120
- reject(err);
121
- } else {
122
- resolve();
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 response = await fetch(url, {
8
- ...options,
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
- ...options.headers,
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 res = await fetch(adminPath + '/api/data-exchange/export/' + modelName, {
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 res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
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 res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
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 res = await fetch(
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
- if (!req) return '127.0.0.1';
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: rows,
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: req.ip || (req.connection && req.connection.remoteAddress) || null,
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: metadata || null,
104
+ metadata: metadataVal,
96
105
  };
97
106
 
98
107
  knex(tableName).insert(row).catch(logAuditError);