mastercontroller 1.3.14 → 1.3.16

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/MasterAction.js CHANGED
@@ -1,13 +1,12 @@
1
1
 
2
2
  // version 0.0.23
3
3
 
4
- var fileserver = require('fs');
5
- var toolClass = require('./MasterTools');
6
- var tools = new toolClass();
4
+ const toolClass = require('./MasterTools');
5
+ const tools = new toolClass();
7
6
  // View templating removed - handled by view engine (e.g., MasterView)
8
7
 
9
8
  // Node utils
10
- var path = require('path');
9
+ const path = require('path');
11
10
 
12
11
  // SSR runtime removed - handled by view engine
13
12
 
@@ -21,8 +20,22 @@ const { generateCSRFToken, validateCSRFToken } = require('./security/SecurityMid
21
20
  const { validator, validateRequestBody, sanitizeObject } = require('./security/MasterValidator');
22
21
  const { sanitizeUserHTML, escapeHTML } = require('./security/MasterSanitizer');
23
22
 
23
+ // HTTP Status Code Constants
24
+ const HTTP_STATUS = {
25
+ OK: 200,
26
+ REDIRECT: 302,
27
+ BAD_REQUEST: 400,
28
+ FORBIDDEN: 403,
29
+ NOT_FOUND: 404,
30
+ PAYLOAD_TOO_LARGE: 413,
31
+ INTERNAL_ERROR: 500
32
+ };
33
+
24
34
  class MasterAction{
25
35
 
36
+ // Maximum response size (10MB default)
37
+ static MAX_RESPONSE_SIZE = 10 * 1024 * 1024;
38
+
26
39
  // Lazy-load master to avoid circular dependency
27
40
  // Static getter ensures single instance (Singleton pattern - Google style)
28
41
  static get _master() {
@@ -34,20 +47,73 @@ class MasterAction{
34
47
 
35
48
  // getView() removed - handled by view engine (register via master.useView())
36
49
 
50
+ /**
51
+ * Check if response is ready for writing (headers not sent)
52
+ * @private
53
+ * @returns {boolean} True if safe to write response
54
+ */
55
+ _isResponseReady() {
56
+ // Try primary response object first
57
+ if (this.__response) {
58
+ return !this.__response._headerSent && !this.__response.headersSent;
59
+ }
60
+ // Try request object response
61
+ if (this.__requestObject && this.__requestObject.response) {
62
+ const resp = this.__requestObject.response;
63
+ return !resp._headerSent && !resp.headersSent;
64
+ }
65
+ // No response object yet (early lifecycle)
66
+ return true;
67
+ }
37
68
 
69
+ /**
70
+ * Returns a JSON response to the client
71
+ * @param {Object|Array|string|number|boolean} data - Data to serialize as JSON
72
+ * @returns {void}
73
+ * @throws {Error} If JSON serialization fails or response already sent
74
+ * @example
75
+ * this.returnJson({ success: true, data: users });
76
+ */
38
77
  returnJson(data){
39
78
  try {
40
- const json = JSON.stringify(data);
41
- // FIXED: Check both _headerSent and headersSent for compatibility
42
- if (!this.__response._headerSent && !this.__response.headersSent) {
43
- this.__response.writeHead(200, {'Content-Type': 'application/json'});
44
- this.__response.end(json);
45
- } else {
79
+ if (!this._isResponseReady()) {
46
80
  logger.warn({
47
81
  code: 'MC_WARN_HEADERS_SENT',
48
82
  message: 'Attempted to send JSON but headers already sent'
49
83
  });
84
+ return;
50
85
  }
86
+
87
+ // Detect circular references
88
+ const seen = new WeakSet();
89
+ const json = JSON.stringify(data, (key, value) => {
90
+ if (typeof value === 'object' && value !== null) {
91
+ if (seen.has(value)) {
92
+ return '[Circular Reference]';
93
+ }
94
+ seen.add(value);
95
+ }
96
+ return value;
97
+ });
98
+
99
+ // Check response size
100
+ const byteSize = Buffer.byteLength(json, 'utf8');
101
+ if (byteSize > MasterAction.MAX_RESPONSE_SIZE) {
102
+ logger.error({
103
+ code: 'MC_ERR_RESPONSE_TOO_LARGE',
104
+ message: 'JSON response exceeds maximum size',
105
+ size: byteSize,
106
+ maxSize: MasterAction.MAX_RESPONSE_SIZE
107
+ });
108
+ this.returnError(HTTP_STATUS.PAYLOAD_TOO_LARGE, 'Response payload too large');
109
+ return;
110
+ }
111
+
112
+ this.__response.writeHead(HTTP_STATUS.OK, {
113
+ 'Content-Type': 'application/json',
114
+ 'Content-Length': byteSize
115
+ });
116
+ this.__response.end(json);
51
117
  } catch (error) {
52
118
  logger.error({
53
119
  code: 'MC_ERR_JSON_SEND',
@@ -55,57 +121,145 @@ class MasterAction{
55
121
  error: error.message,
56
122
  stack: error.stack
57
123
  });
124
+
125
+ // Attempt to send error response if possible
126
+ if (this._isResponseReady()) {
127
+ this.returnError(HTTP_STATUS.INTERNAL_ERROR, 'Internal server error');
128
+ }
58
129
  }
59
130
  }
60
131
 
61
132
  // returnPartialView() removed - handled by view engine (register via master.useView())
62
133
 
134
+ /**
135
+ * Redirects to the previous page (HTTP referer) with fallback
136
+ * @param {string} [fallback='/'] - Fallback URL if referer is invalid
137
+ * @returns {void}
138
+ * @security Only allows same-origin redirects to prevent open redirect attacks
139
+ * @example
140
+ * this.redirectBack('/home'); // Fallback to /home if referer invalid
141
+ */
63
142
  redirectBack(fallback){
64
- if(fallback === undefined){
65
- var backUrl = this.__requestObject.request.headers.referer === "" ? "/" : this.__requestObject.request.headers.referer
66
- this.redirectTo(backUrl);
67
- }
68
- else{
143
+ const referer = this.__requestObject.request.headers.referer || "";
144
+
145
+ // Validate referer is same-origin or allowed domain
146
+ if (referer && this._isValidRedirectUrl(referer)) {
147
+ this.redirectTo(referer);
148
+ } else if (fallback !== undefined) {
69
149
  this.redirectTo(fallback);
150
+ } else {
151
+ this.redirectTo("/");
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Validate URL is safe for redirect (same-origin only)
157
+ * @private
158
+ * @param {string} url - URL to validate
159
+ * @returns {boolean} True if URL is safe for redirect
160
+ */
161
+ _isValidRedirectUrl(url) {
162
+ try {
163
+ const urlObj = new URL(url, `http://${this.__requestObject.request.headers.host}`);
164
+ const requestHost = this.__requestObject.request.headers.host;
165
+
166
+ // Only allow same-origin redirects
167
+ return urlObj.host === requestHost;
168
+ } catch (e) {
169
+ return false;
70
170
  }
71
171
  }
72
172
 
73
- // redirects to another controller = does not reload the page
74
- redirectTo(url, obj){
75
-
76
- var parseUrl = url.replace(/\/$/, ""); // /board/
77
-
78
- var queryString = "/?";
79
- var objCounter = 0;
80
- for (var key in obj) {
81
- if (obj.hasOwnProperty(key)) {
82
- if(key === "id"){
83
- parseUrl = parseUrl + "/" + obj[key]; // /board/5
84
- }else{
85
- objCounter++;
86
- if(objCounter > 1){
87
- queryString = queryString + "&";
173
+ /**
174
+ * Validate URL is safe and properly formatted for redirect
175
+ * @private
176
+ * @param {string} url - URL to validate
177
+ * @throws {Error} If URL is invalid or dangerous
178
+ */
179
+ _validateRedirectUrl(url) {
180
+ if (!url || typeof url !== 'string') {
181
+ throw new Error('Invalid redirect URL: must be non-empty string');
182
+ }
183
+
184
+ // Prevent protocol-relative URLs (//evil.com)
185
+ if (url.startsWith('//')) {
186
+ throw new Error('Invalid redirect URL: protocol-relative URLs not allowed');
187
+ }
188
+
189
+ // Prevent javascript: or data: URLs
190
+ if (/^(javascript|data|vbscript|file):/i.test(url)) {
191
+ throw new Error('Invalid redirect URL: dangerous protocol detected');
192
+ }
193
+
194
+ return true;
195
+ }
196
+
197
+ /**
198
+ * Redirects to another route via HTTP 302
199
+ * @param {string} url - Target URL path
200
+ * @param {Object} [obj={}] - Optional parameters (id goes to path, others to query string)
201
+ * @returns {void}
202
+ * @security All parameters are URL-encoded to prevent injection
203
+ * @example
204
+ * this.redirectTo('/users', { id: 5, filter: 'active' }); // → /users/5?filter=active
205
+ */
206
+ redirectTo(url, obj = {}) {
207
+ try {
208
+ this._validateRedirectUrl(url);
209
+
210
+ const parseUrl = url.replace(/\/$/, "");
211
+ const queryParams = [];
212
+ let idParam = null;
213
+
214
+ for (const key in obj) {
215
+ if (obj.hasOwnProperty(key)) {
216
+ // Encode all values to prevent injection
217
+ const encodedValue = encodeURIComponent(String(obj[key]));
218
+
219
+ if (key === "id") {
220
+ idParam = encodedValue;
221
+ } else {
222
+ queryParams.push(`${encodeURIComponent(key)}=${encodedValue}`);
88
223
  }
89
- queryString = queryString + key + "=" + obj[key]; //?james=dfdfd&queryString
90
224
  }
91
- //?james=dfdfd&rih=sdsd&
92
225
  }
93
- };
94
226
 
95
- var doneParsedUrl = objCounter >= 1 ? parseUrl + queryString : parseUrl; // /boards?james=dfdfd&rih=sdsd&
227
+ let finalUrl = parseUrl;
228
+ if (idParam) {
229
+ finalUrl = `${finalUrl}/${idParam}`;
230
+ }
231
+ if (queryParams.length > 0) {
232
+ finalUrl = `${finalUrl}?${queryParams.join('&')}`;
233
+ }
96
234
 
97
- if (!this.__requestObject.response._headerSent) {
98
- this.__requestObject.response.writeHead(302, {
99
- 'Location': doneParsedUrl
100
- //add other headers here...
235
+ if (this._isResponseReady()) {
236
+ this.__requestObject.response.writeHead(HTTP_STATUS.REDIRECT, {
237
+ 'Location': finalUrl
238
+ });
239
+ this.__requestObject.response.end();
240
+ }
241
+ } catch (error) {
242
+ logger.error({
243
+ code: 'MC_ERR_INVALID_REDIRECT',
244
+ message: error.message,
245
+ url
101
246
  });
102
- this.__requestObject.response.end();
247
+ this.returnError(HTTP_STATUS.BAD_REQUEST, 'Invalid redirect URL');
103
248
  }
104
-
105
249
  }
106
250
 
107
251
 
108
- // redirects to another action inside the same controller = does not reload the page
252
+ /**
253
+ * Internal redirect to another controller action (no HTTP redirect)
254
+ * @param {string} namespace - Controller name
255
+ * @param {string} action - Action/method name
256
+ * @param {string} type - Request type (GET, POST, etc.)
257
+ * @param {Object} data - Parameters to pass
258
+ * @param {boolean} [components=false] - Whether this is a component controller
259
+ * @returns {void}
260
+ * @example
261
+ * this.redirectToAction('users', 'show', 'GET', { id: 5 });
262
+ */
109
263
  redirectToAction(namespace, action, type, data, components){
110
264
  // FIXED: Declare variables before if/else to avoid undefined reference
111
265
  const resp = this.__requestObject.response;
@@ -147,33 +301,46 @@ class MasterAction{
147
301
 
148
302
  // returnView() removed - handled by view engine (register via master.useView())
149
303
 
304
+ /**
305
+ * Close HTTP response with specified content
306
+ * @param {Object} response - HTTP response object
307
+ * @param {number} code - HTTP status code
308
+ * @param {Object} content - Content configuration with type property
309
+ * @param {string} end - Response body content
310
+ * @returns {void}
311
+ * @example
312
+ * this.close(response, 200, { type: { 'Content-Type': 'text/html' } }, '<h1>Hello</h1>');
313
+ */
150
314
  close(response, code, content, end){
151
315
  response.writeHead(code, content.type);
152
316
  response.end(end);
153
317
  }
154
318
 
155
- // Utility method to check if response is ready for writing
156
- // Returns true if safe to continue, false if response already sent
319
+ /**
320
+ * Utility method to check if response is ready for writing
321
+ * @returns {boolean} True if safe to continue, false if response already sent
322
+ * @deprecated Use _isResponseReady() instead
323
+ */
157
324
  waitUntilReady(){
158
- // Check the primary response object first (matches existing returnJson pattern)
159
- if (this.__response) {
160
- return !this.__response._headerSent;
161
- }
162
- // Check request object response as fallback (matches existing redirectTo pattern)
163
- if (this.__requestObject && this.__requestObject.response) {
164
- return !this.__requestObject.response._headerSent;
165
- }
166
- // If neither exists, assume it's safe to continue (early in request lifecycle)
167
- return true;
325
+ return this._isResponseReady();
168
326
  }
169
327
 
170
- // Enhanced returnJson that checks readiness first
328
+ /**
329
+ * Safe version of returnJson that checks readiness first
330
+ * @param {Object|Array|string|number|boolean} data - Data to serialize as JSON
331
+ * @returns {boolean} True if sent successfully, false if headers already sent
332
+ * @example
333
+ * if (!this.safeReturnJson({ data })) { logger.warn('Response already sent'); }
334
+ */
171
335
  safeReturnJson(data){
172
336
  if (this.waitUntilReady()) {
173
337
  this.returnJson(data);
174
338
  return true;
175
339
  }
176
- console.warn('Attempted to send JSON response but headers already sent');
340
+ logger.warn({
341
+ code: 'MC_WARN_SAFE_RETURN_JSON_FAILED',
342
+ message: 'Attempted to send JSON response but headers already sent'
343
+ });
177
344
  return false;
178
345
  }
179
346
 
@@ -181,6 +348,60 @@ class MasterAction{
181
348
 
182
349
  // returnWebComponent() removed - handled by view engine (register via master.useView())
183
350
 
351
+ // ==================== Observability Methods ====================
352
+
353
+ /**
354
+ * Get or generate request ID for tracing
355
+ * @returns {string} Unique request identifier
356
+ * @example
357
+ * const reqId = this.getRequestId(); // req_1234567890_abc123
358
+ */
359
+ getRequestId() {
360
+ if (!this.__requestId) {
361
+ this.__requestId = this.__requestObject?.headers?.['x-request-id'] ||
362
+ `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
363
+ }
364
+ return this.__requestId;
365
+ }
366
+
367
+ /**
368
+ * Record timing metric for observability
369
+ * @param {string} metric - Metric name
370
+ * @param {number} duration - Duration in milliseconds
371
+ * @returns {void}
372
+ * @example
373
+ * const start = Date.now();
374
+ * // ... do work ...
375
+ * this.recordTiming('db_query', Date.now() - start);
376
+ */
377
+ recordTiming(metric, duration) {
378
+ logger.info({
379
+ code: 'MC_METRIC_TIMING',
380
+ metric,
381
+ duration,
382
+ requestId: this.getRequestId(),
383
+ path: this.__requestObject?.pathName
384
+ });
385
+ }
386
+
387
+ /**
388
+ * Check if request should be rate limited
389
+ * @returns {boolean} True if request allowed
390
+ * @example
391
+ * if (!this.checkRateLimit()) {
392
+ * return this.returnError(429, 'Too many requests');
393
+ * }
394
+ */
395
+ checkRateLimit() {
396
+ // Hook for rate limiting middleware
397
+ if (MasterAction._master.rateLimiter) {
398
+ const clientIp = this.__requestObject?.request?.headers?.['x-forwarded-for'] ||
399
+ this.__requestObject?.request?.connection?.remoteAddress;
400
+ return MasterAction._master.rateLimiter.check(clientIp, this.__requestObject?.pathName);
401
+ }
402
+ return true;
403
+ }
404
+
184
405
  // ==================== Security Methods ====================
185
406
 
186
407
  /**
@@ -322,7 +543,7 @@ class MasterAction{
322
543
  code: 'MC_CONFIG_MISSING_HOSTNAME',
323
544
  message: 'requireHTTPS called but no hostname configured in MasterAction._master.env.server.hostname'
324
545
  });
325
- this.returnError(500, 'Server misconfiguration');
546
+ this.returnError(HTTP_STATUS.INTERNAL_ERROR, 'Server misconfiguration');
326
547
  return false;
327
548
  }
328
549
 
@@ -335,19 +556,38 @@ class MasterAction{
335
556
 
336
557
  /**
337
558
  * Return error response with proper status
338
- * Usage: this.returnError(400, 'Invalid input');
559
+ * @param {number} statusCode - HTTP status code
560
+ * @param {string} message - Error message
561
+ * @param {Object} [details={}] - Additional error details
562
+ * @returns {void}
563
+ * @example
564
+ * this.returnError(400, 'Invalid input');
339
565
  */
340
566
  returnError(statusCode, message, details = {}) {
341
- const res = this.__response || (this.__requestObject && this.__requestObject.response);
567
+ if (this._isResponseReady()) {
568
+ const res = this.__response || (this.__requestObject && this.__requestObject.response);
342
569
 
343
- if (res && !res._headerSent) {
344
- res.writeHead(statusCode, { 'Content-Type': 'application/json' });
345
- res.end(JSON.stringify({
570
+ const errorResponse = {
346
571
  error: true,
347
572
  statusCode,
348
573
  message,
574
+ timestamp: new Date().toISOString(),
575
+ path: this.__requestObject?.pathName,
576
+ method: this.__requestObject?.request?.method,
349
577
  ...details
350
- }));
578
+ };
579
+
580
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
581
+ res.end(JSON.stringify(errorResponse));
582
+
583
+ // Log error for monitoring
584
+ logger.error({
585
+ code: 'MC_ERR_CLIENT_ERROR',
586
+ statusCode,
587
+ message,
588
+ path: errorResponse.path,
589
+ method: errorResponse.method
590
+ });
351
591
  }
352
592
  }
353
593