mastercontroller 1.2.14 → 1.3.1

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.
@@ -0,0 +1,529 @@
1
+ /**
2
+ * MasterErrorRenderer - Professional error page rendering system
3
+ *
4
+ * Inspired by Rails ActionDispatch::ExceptionWrapper and Django error views
5
+ *
6
+ * Features:
7
+ * - Environment-specific rendering (dev vs production)
8
+ * - Dynamic error pages with template data
9
+ * - Multiple error codes (401, 403, 404, 422, 429, 500, 503, etc.)
10
+ * - Content negotiation (HTML vs JSON)
11
+ * - Custom error handlers
12
+ * - Template-based error pages
13
+ *
14
+ * @version 1.0.0
15
+ */
16
+
17
+ var master = require('../MasterControl');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { logger } = require('./MasterErrorLogger');
21
+
22
+ class MasterErrorRenderer {
23
+ constructor() {
24
+ this.errorTemplates = new Map();
25
+ this.customHandlers = new Map();
26
+ this.templateDir = null;
27
+ this.environment = 'development';
28
+ }
29
+
30
+ /**
31
+ * Initialize error renderer
32
+ *
33
+ * @param {Object} options - Configuration options
34
+ * @param {String} options.templateDir - Directory for error templates (default: 'public/errors')
35
+ * @param {String} options.environment - Environment (development, production, test)
36
+ * @param {Boolean} options.showStackTrace - Show stack traces in dev (default: true in dev)
37
+ */
38
+ init(options = {}) {
39
+ this.templateDir = options.templateDir || path.join(master.root, 'public/errors');
40
+ this.environment = options.environment || master.environmentType || 'development';
41
+ this.showStackTrace = options.showStackTrace !== undefined
42
+ ? options.showStackTrace
43
+ : (this.environment === 'development');
44
+
45
+ // Create error templates directory if it doesn't exist
46
+ if (!fs.existsSync(this.templateDir)) {
47
+ fs.mkdirSync(this.templateDir, { recursive: true });
48
+ logger.info({
49
+ code: 'MC_ERROR_RENDERER_DIR_CREATED',
50
+ message: 'Created error templates directory',
51
+ dir: this.templateDir
52
+ });
53
+ }
54
+
55
+ // Load error templates
56
+ this._loadTemplates();
57
+
58
+ logger.info({
59
+ code: 'MC_ERROR_RENDERER_INIT',
60
+ message: 'Error renderer initialized',
61
+ templateDir: this.templateDir,
62
+ environment: this.environment,
63
+ showStackTrace: this.showStackTrace
64
+ });
65
+
66
+ return this;
67
+ }
68
+
69
+ /**
70
+ * Render error page
71
+ *
72
+ * @param {Object} ctx - Request context
73
+ * @param {Number} statusCode - HTTP status code
74
+ * @param {Object} errorData - Error data
75
+ * @returns {String} - Rendered HTML or JSON
76
+ */
77
+ render(ctx, statusCode, errorData = {}) {
78
+ const isApiRequest = this._isApiRequest(ctx);
79
+
80
+ if (isApiRequest) {
81
+ return this._renderJSON(statusCode, errorData);
82
+ } else {
83
+ return this._renderHTML(ctx, statusCode, errorData);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Send error response
89
+ *
90
+ * @param {Object} ctx - Request context
91
+ * @param {Number} statusCode - HTTP status code
92
+ * @param {Object} errorData - Error data
93
+ */
94
+ send(ctx, statusCode, errorData = {}) {
95
+ const content = this.render(ctx, statusCode, errorData);
96
+ const isApiRequest = this._isApiRequest(ctx);
97
+
98
+ if (!ctx.response.headersSent) {
99
+ ctx.response.statusCode = statusCode;
100
+ ctx.response.setHeader('Content-Type', isApiRequest ? 'application/json' : 'text/html');
101
+ ctx.response.end(content);
102
+ }
103
+
104
+ // Log error
105
+ logger.error({
106
+ code: errorData.code || 'MC_HTTP_ERROR',
107
+ message: errorData.message || this._getDefaultMessage(statusCode),
108
+ statusCode,
109
+ path: ctx.pathName || ctx.request.url,
110
+ method: ctx.type || ctx.request.method.toLowerCase(),
111
+ stack: errorData.stack
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Register custom error handler for specific status code
117
+ *
118
+ * @param {Number} statusCode - HTTP status code
119
+ * @param {Function} handler - Handler function (ctx, errorData) => String
120
+ *
121
+ * @example
122
+ * master.errorRenderer.registerHandler(404, (ctx, errorData) => {
123
+ * return `<html><body>Custom 404: ${errorData.message}</body></html>`;
124
+ * });
125
+ */
126
+ registerHandler(statusCode, handler) {
127
+ if (typeof handler !== 'function') {
128
+ throw new Error('Handler must be a function');
129
+ }
130
+
131
+ this.customHandlers.set(statusCode, handler);
132
+
133
+ logger.info({
134
+ code: 'MC_ERROR_HANDLER_REGISTERED',
135
+ message: 'Custom error handler registered',
136
+ statusCode
137
+ });
138
+
139
+ return this;
140
+ }
141
+
142
+ /**
143
+ * Render HTML error page
144
+ *
145
+ * @private
146
+ */
147
+ _renderHTML(ctx, statusCode, errorData) {
148
+ // Check for custom handler
149
+ if (this.customHandlers.has(statusCode)) {
150
+ try {
151
+ return this.customHandlers.get(statusCode)(ctx, errorData);
152
+ } catch (err) {
153
+ logger.error({
154
+ code: 'MC_ERROR_HANDLER_FAILED',
155
+ message: 'Custom error handler failed',
156
+ statusCode,
157
+ error: err.message
158
+ });
159
+ // Fall through to default rendering
160
+ }
161
+ }
162
+
163
+ // Check for template
164
+ const template = this._getTemplate(statusCode);
165
+ if (template) {
166
+ return this._renderTemplate(template, statusCode, errorData);
167
+ }
168
+
169
+ // Fallback to default error page
170
+ return this._renderDefaultHTML(statusCode, errorData);
171
+ }
172
+
173
+ /**
174
+ * Render JSON error response
175
+ *
176
+ * @private
177
+ */
178
+ _renderJSON(statusCode, errorData) {
179
+ const response = {
180
+ error: this._getDefaultMessage(statusCode),
181
+ statusCode: statusCode,
182
+ code: errorData.code || 'MC_HTTP_ERROR'
183
+ };
184
+
185
+ if (errorData.message) {
186
+ response.message = errorData.message;
187
+ }
188
+
189
+ if (this.showStackTrace && errorData.stack) {
190
+ response.stack = errorData.stack;
191
+ }
192
+
193
+ if (errorData.details) {
194
+ response.details = errorData.details;
195
+ }
196
+
197
+ if (errorData.suggestions) {
198
+ response.suggestions = errorData.suggestions;
199
+ }
200
+
201
+ return JSON.stringify(response, null, 2);
202
+ }
203
+
204
+ /**
205
+ * Render template with data
206
+ *
207
+ * @private
208
+ */
209
+ _renderTemplate(template, statusCode, errorData) {
210
+ let html = template;
211
+
212
+ const data = {
213
+ statusCode: statusCode,
214
+ title: this._getDefaultTitle(statusCode),
215
+ message: errorData.message || this._getDefaultMessage(statusCode),
216
+ description: errorData.description || '',
217
+ code: errorData.code || 'MC_HTTP_ERROR',
218
+ stack: this.showStackTrace && errorData.stack ? errorData.stack : null,
219
+ suggestions: errorData.suggestions || [],
220
+ path: errorData.path || '',
221
+ environment: this.environment,
222
+ showStackTrace: this.showStackTrace
223
+ };
224
+
225
+ // Simple template rendering (replace {{key}} with values)
226
+ for (const [key, value] of Object.entries(data)) {
227
+ const regex = new RegExp(`{{${key}}}`, 'g');
228
+ html = html.replace(regex, value || '');
229
+ }
230
+
231
+ // Handle conditionals {{#if showStackTrace}}...{{/if}}
232
+ html = this._processConditionals(html, data);
233
+
234
+ // Handle loops {{#each suggestions}}...{{/each}}
235
+ html = this._processLoops(html, data);
236
+
237
+ return html;
238
+ }
239
+
240
+ /**
241
+ * Process conditional blocks in template
242
+ *
243
+ * @private
244
+ */
245
+ _processConditionals(html, data) {
246
+ const conditionalRegex = /{{#if\s+(\w+)}}([\s\S]*?){{\/if}}/g;
247
+
248
+ return html.replace(conditionalRegex, (match, condition, content) => {
249
+ return data[condition] ? content : '';
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Process loop blocks in template
255
+ *
256
+ * @private
257
+ */
258
+ _processLoops(html, data) {
259
+ const loopRegex = /{{#each\s+(\w+)}}([\s\S]*?){{\/each}}/g;
260
+
261
+ return html.replace(loopRegex, (match, arrayName, content) => {
262
+ const array = data[arrayName];
263
+ if (!Array.isArray(array)) {
264
+ return '';
265
+ }
266
+
267
+ return array.map(item => {
268
+ let itemHtml = content;
269
+ if (typeof item === 'string') {
270
+ itemHtml = itemHtml.replace(/{{this}}/g, item);
271
+ } else if (typeof item === 'object') {
272
+ for (const [key, value] of Object.entries(item)) {
273
+ itemHtml = itemHtml.replace(new RegExp(`{{${key}}}`, 'g'), value);
274
+ }
275
+ }
276
+ return itemHtml;
277
+ }).join('');
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Render default HTML error page
283
+ *
284
+ * @private
285
+ */
286
+ _renderDefaultHTML(statusCode, errorData) {
287
+ const title = this._getDefaultTitle(statusCode);
288
+ const message = errorData.message || this._getDefaultMessage(statusCode);
289
+ const stack = this.showStackTrace && errorData.stack
290
+ ? `<pre style="background: #f5f5f5; padding: 1em; overflow: auto;">${this._escapeHtml(errorData.stack)}</pre>`
291
+ : '';
292
+
293
+ const suggestions = errorData.suggestions && errorData.suggestions.length > 0
294
+ ? `<div style="margin-top: 2em;">
295
+ <h3>Suggestions:</h3>
296
+ <ul>
297
+ ${errorData.suggestions.map(s => `<li>${this._escapeHtml(s)}</li>`).join('')}
298
+ </ul>
299
+ </div>`
300
+ : '';
301
+
302
+ return `<!DOCTYPE html>
303
+ <html>
304
+ <head>
305
+ <title>${title}</title>
306
+ <meta name="viewport" content="width=device-width,initial-scale=1">
307
+ <style>
308
+ body {
309
+ background-color: #f8f9fa;
310
+ color: #212529;
311
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
312
+ margin: 0;
313
+ padding: 2em;
314
+ }
315
+ .container {
316
+ max-width: 800px;
317
+ margin: 0 auto;
318
+ }
319
+ .error-box {
320
+ background: white;
321
+ border-left: 4px solid #dc3545;
322
+ border-radius: 4px;
323
+ padding: 2em;
324
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
325
+ }
326
+ h1 {
327
+ margin: 0 0 0.5em 0;
328
+ color: #dc3545;
329
+ font-size: 2em;
330
+ }
331
+ .status-code {
332
+ font-size: 4em;
333
+ font-weight: bold;
334
+ color: #dc3545;
335
+ margin: 0;
336
+ }
337
+ .message {
338
+ font-size: 1.2em;
339
+ margin: 1em 0;
340
+ }
341
+ .code {
342
+ background: #f8f9fa;
343
+ padding: 0.5em 1em;
344
+ border-radius: 4px;
345
+ font-family: monospace;
346
+ font-size: 0.9em;
347
+ margin: 1em 0;
348
+ }
349
+ pre {
350
+ background: #f5f5f5;
351
+ padding: 1em;
352
+ overflow: auto;
353
+ border-radius: 4px;
354
+ }
355
+ .footer {
356
+ margin-top: 2em;
357
+ text-align: center;
358
+ color: #6c757d;
359
+ font-size: 0.9em;
360
+ }
361
+ ul {
362
+ line-height: 1.6;
363
+ }
364
+ </style>
365
+ </head>
366
+ <body>
367
+ <div class="container">
368
+ <div class="error-box">
369
+ <p class="status-code">${statusCode}</p>
370
+ <h1>${title}</h1>
371
+ <p class="message">${this._escapeHtml(message)}</p>
372
+ ${errorData.code ? `<div class="code">Error Code: ${errorData.code}</div>` : ''}
373
+ ${suggestions}
374
+ ${stack}
375
+ </div>
376
+ <div class="footer">
377
+ <p>MasterController Framework • ${this.environment} environment</p>
378
+ </div>
379
+ </div>
380
+ </body>
381
+ </html>`;
382
+ }
383
+
384
+ /**
385
+ * Load error templates from disk
386
+ *
387
+ * @private
388
+ */
389
+ _loadTemplates() {
390
+ const statusCodes = [400, 401, 403, 404, 405, 422, 429, 500, 502, 503, 504];
391
+
392
+ for (const code of statusCodes) {
393
+ const templatePath = path.join(this.templateDir, `${code}.html`);
394
+
395
+ if (fs.existsSync(templatePath)) {
396
+ try {
397
+ const template = fs.readFileSync(templatePath, 'utf8');
398
+ this.errorTemplates.set(code, template);
399
+ logger.info({
400
+ code: 'MC_ERROR_TEMPLATE_LOADED',
401
+ message: 'Error template loaded',
402
+ statusCode: code,
403
+ path: templatePath
404
+ });
405
+ } catch (err) {
406
+ logger.error({
407
+ code: 'MC_ERROR_TEMPLATE_LOAD_FAILED',
408
+ message: 'Failed to load error template',
409
+ statusCode: code,
410
+ error: err.message
411
+ });
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Get template for status code
419
+ *
420
+ * @private
421
+ */
422
+ _getTemplate(statusCode) {
423
+ // Check exact match
424
+ if (this.errorTemplates.has(statusCode)) {
425
+ return this.errorTemplates.get(statusCode);
426
+ }
427
+
428
+ // Check category (4xx -> 400, 5xx -> 500)
429
+ const category = Math.floor(statusCode / 100) * 100;
430
+ if (this.errorTemplates.has(category)) {
431
+ return this.errorTemplates.get(category);
432
+ }
433
+
434
+ return null;
435
+ }
436
+
437
+ /**
438
+ * Check if request is API request
439
+ *
440
+ * @private
441
+ */
442
+ _isApiRequest(ctx) {
443
+ // Check Accept header
444
+ const accept = ctx.request.headers['accept'] || '';
445
+ if (accept.includes('application/json')) {
446
+ return true;
447
+ }
448
+
449
+ // Check path
450
+ const path = ctx.pathName || ctx.request.url;
451
+ if (path.startsWith('api/') || path.startsWith('/api/')) {
452
+ return true;
453
+ }
454
+
455
+ // Check Content-Type
456
+ const contentType = ctx.request.headers['content-type'] || '';
457
+ if (contentType.includes('application/json')) {
458
+ return true;
459
+ }
460
+
461
+ return false;
462
+ }
463
+
464
+ /**
465
+ * Get default title for status code
466
+ *
467
+ * @private
468
+ */
469
+ _getDefaultTitle(statusCode) {
470
+ const titles = {
471
+ 400: 'Bad Request',
472
+ 401: 'Unauthorized',
473
+ 403: 'Forbidden',
474
+ 404: 'Page Not Found',
475
+ 405: 'Method Not Allowed',
476
+ 422: 'Unprocessable Entity',
477
+ 429: 'Too Many Requests',
478
+ 500: 'Internal Server Error',
479
+ 502: 'Bad Gateway',
480
+ 503: 'Service Unavailable',
481
+ 504: 'Gateway Timeout'
482
+ };
483
+
484
+ return titles[statusCode] || `Error ${statusCode}`;
485
+ }
486
+
487
+ /**
488
+ * Get default message for status code
489
+ *
490
+ * @private
491
+ */
492
+ _getDefaultMessage(statusCode) {
493
+ const messages = {
494
+ 400: 'The request could not be understood by the server due to malformed syntax.',
495
+ 401: 'You need to be authenticated to access this resource.',
496
+ 403: 'You don\'t have permission to access this resource.',
497
+ 404: 'The page you were looking for doesn\'t exist.',
498
+ 405: 'The method specified in the request is not allowed for this resource.',
499
+ 422: 'The request was well-formed but contains invalid data.',
500
+ 429: 'Too many requests. Please slow down and try again later.',
501
+ 500: 'We\'re sorry, but something went wrong on our end.',
502
+ 502: 'The server received an invalid response from the upstream server.',
503
+ 503: 'The service is temporarily unavailable. Please try again later.',
504
+ 504: 'The server did not receive a timely response from the upstream server.'
505
+ };
506
+
507
+ return messages[statusCode] || 'An error occurred while processing your request.';
508
+ }
509
+
510
+ /**
511
+ * Escape HTML entities
512
+ *
513
+ * @private
514
+ */
515
+ _escapeHtml(text) {
516
+ if (!text) return '';
517
+ return text
518
+ .toString()
519
+ .replace(/&/g, '&amp;')
520
+ .replace(/</g, '&lt;')
521
+ .replace(/>/g, '&gt;')
522
+ .replace(/"/g, '&quot;')
523
+ .replace(/'/g, '&#039;');
524
+ }
525
+ }
526
+
527
+ master.extend("errorRenderer", MasterErrorRenderer);
528
+
529
+ module.exports = MasterErrorRenderer;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "dependencies": {
3
- "qs" : "^6.14.0",
3
+ "qs" : "^6.14.1",
4
4
  "formidable": "^3.5.4",
5
- "cookie": "^1.0.2",
6
- "winston": "^3.17.0",
7
- "glob" :"^11.0.3"
5
+ "cookie": "^1.1.1",
6
+ "winston": "^3.19.0",
7
+ "glob" :"^13.0.0"
8
8
  },
9
9
  "description": "A class library that makes using the Master Framework a breeze",
10
10
  "homepage": "https://github.com/Tailor/MasterController#readme",
@@ -18,5 +18,5 @@
18
18
  "scripts": {
19
19
  "test": "echo \"Error: no test specified\" && exit 1"
20
20
  },
21
- "version": "1.2.14"
21
+ "version": "1.3.1"
22
22
  }
@@ -473,6 +473,73 @@ function validateCSRFToken(token) {
473
473
  return security.validateCSRFToken(token);
474
474
  }
475
475
 
476
+ /**
477
+ * Pipeline-compatible middleware wrappers
478
+ * These adapt from (ctx, next) format to (req, res, next) format
479
+ */
480
+
481
+ function pipelineSecurityHeaders(options = {}) {
482
+ const instance = options.instance || security;
483
+ return async (ctx, next) => {
484
+ // Create next callback for old-style middleware
485
+ let nextCalled = false;
486
+ const oldNext = () => { nextCalled = true; };
487
+
488
+ // Call old middleware
489
+ instance.securityHeadersMiddleware(ctx.request, ctx.response, oldNext);
490
+
491
+ // Continue pipeline if next was called
492
+ if (nextCalled) {
493
+ await next();
494
+ }
495
+ };
496
+ }
497
+
498
+ function pipelineCors(options = {}) {
499
+ const instance = new SecurityMiddleware({ ...options, headers: false, csrf: false, rateLimit: false });
500
+ return async (ctx, next) => {
501
+ let nextCalled = false;
502
+ const oldNext = () => { nextCalled = true; };
503
+
504
+ instance.corsMiddleware(ctx.request, ctx.response, oldNext);
505
+
506
+ // CORS might terminate for OPTIONS - check if response ended
507
+ if (!ctx.response.writableEnded && nextCalled) {
508
+ await next();
509
+ }
510
+ };
511
+ }
512
+
513
+ function pipelineRateLimit(options = {}) {
514
+ const instance = new SecurityMiddleware({ ...options, headers: false, csrf: false, cors: false });
515
+ return async (ctx, next) => {
516
+ let nextCalled = false;
517
+ const oldNext = () => { nextCalled = true; };
518
+
519
+ instance.rateLimitMiddleware(ctx.request, ctx.response, oldNext);
520
+
521
+ // Rate limit might terminate - check if response ended
522
+ if (!ctx.response.writableEnded && nextCalled) {
523
+ await next();
524
+ }
525
+ };
526
+ }
527
+
528
+ function pipelineCsrf(options = {}) {
529
+ const instance = new SecurityMiddleware({ ...options, headers: false, cors: false, rateLimit: false });
530
+ return async (ctx, next) => {
531
+ let nextCalled = false;
532
+ const oldNext = () => { nextCalled = true; };
533
+
534
+ instance.csrfMiddleware(ctx.request, ctx.response, oldNext);
535
+
536
+ // CSRF might terminate - check if response ended
537
+ if (!ctx.response.writableEnded && nextCalled) {
538
+ await next();
539
+ }
540
+ };
541
+ }
542
+
476
543
  module.exports = {
477
544
  SecurityMiddleware,
478
545
  security,
@@ -482,5 +549,10 @@ module.exports = {
482
549
  csrf,
483
550
  generateCSRFToken,
484
551
  validateCSRFToken,
485
- SECURITY_HEADERS
552
+ SECURITY_HEADERS,
553
+ // Pipeline-compatible exports
554
+ pipelineSecurityHeaders,
555
+ pipelineCors,
556
+ pipelineRateLimit,
557
+ pipelineCsrf
486
558
  };