From cc8073256af9772da74f47490cce3e989aa48d46 Mon Sep 17 00:00:00 2001 From: Sami Date: Sat, 28 Mar 2026 19:01:24 -0700 Subject: [PATCH] refactor: Phase 2 - add error handling modules and response helpers --- dashcaddy-api/error-handler.js | 85 +++++++++++++++++++ dashcaddy-api/error-logger.js | 135 ++++++++++++++++++++++++++++++ dashcaddy-api/response-helpers.js | 114 +++++++++++++++++++++++++ dashcaddy-api/server.js | 2 + 4 files changed, 336 insertions(+) create mode 100644 dashcaddy-api/error-handler.js create mode 100644 dashcaddy-api/error-logger.js create mode 100644 dashcaddy-api/response-helpers.js diff --git a/dashcaddy-api/error-handler.js b/dashcaddy-api/error-handler.js new file mode 100644 index 0000000..3e0dca2 --- /dev/null +++ b/dashcaddy-api/error-handler.js @@ -0,0 +1,85 @@ +// Error Handler Middleware +// Centralizes error handling logic to eliminate duplicate catch blocks + +const { HTTP_STATUS } = require('./constants'); + +/** + * Async route handler wrapper - catches errors and passes to error middleware + * Usage: app.get('/route', asyncHandler(async (req, res) => { ... })) + */ +function asyncHandler(fn) { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} + +/** + * Express error middleware - handles all errors consistently + */ +function errorMiddleware(err, req, res, next) { + const logger = req.app.get('logger'); + + // Log the error with context + logger.error('Request error', { + error: err.message, + stack: err.stack, + path: req.path, + method: req.method, + ip: req.ip, + userId: req.user?.id + }); + + // Determine status code + const statusCode = err.statusCode || err.status || HTTP_STATUS.INTERNAL_ERROR; + + // Send consistent error response + res.status(statusCode).json({ + success: false, + error: err.message || 'Internal server error', + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) + }); +} + +/** + * Custom error classes for specific scenarios + */ +class ValidationError extends Error { + constructor(message) { + super(message); + this.name = 'ValidationError'; + this.statusCode = HTTP_STATUS.BAD_REQUEST; + } +} + +class UnauthorizedError extends Error { + constructor(message = 'Unauthorized') { + super(message); + this.name = 'UnauthorizedError'; + this.statusCode = HTTP_STATUS.UNAUTHORIZED; + } +} + +class NotFoundError extends Error { + constructor(message = 'Not found') { + super(message); + this.name = 'NotFoundError'; + this.statusCode = HTTP_STATUS.NOT_FOUND; + } +} + +class ConflictError extends Error { + constructor(message) { + super(message); + this.name = 'ConflictError'; + this.statusCode = HTTP_STATUS.CONFLICT; + } +} + +module.exports = { + asyncHandler, + errorMiddleware, + ValidationError, + UnauthorizedError, + NotFoundError, + ConflictError +}; diff --git a/dashcaddy-api/error-logger.js b/dashcaddy-api/error-logger.js new file mode 100644 index 0000000..e35d337 --- /dev/null +++ b/dashcaddy-api/error-logger.js @@ -0,0 +1,135 @@ +// Error Logger Utility +// Centralized error logging with rotation and request context tracking + +const fsp = require('fs').promises; +const path = require('path'); +const { LIMITS } = require('./constants'); + +const ERROR_LOG_FILE = path.join(__dirname, 'error.log'); +const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE; + +/** + * Check if file exists + */ +async function exists(filepath) { + try { + await fsp.access(filepath); + return true; + } catch { + return false; + } +} + +/** + * Log error with context and rotation + * @param {string} context - Where the error occurred + * @param {Error|string} error - The error to log + * @param {Object} additionalInfo - Additional context (req, etc.) + */ +async function logError(context, error, additionalInfo = {}) { + const timestamp = new Date().toISOString(); + + // Extract request context if a request object is provided + const requestContext = extractRequestContext(additionalInfo.req); + if (additionalInfo.req) { + delete additionalInfo.req; // Remove req to avoid circular refs + } + + const logEntry = { + timestamp, + context, + ...requestContext, + error: { + message: error.message || error, + stack: error.stack, + code: error.code + }, + ...additionalInfo + }; + + // Format log line with request context + const contextInfo = Object.keys(requestContext).length > 0 + ? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}` + : ''; + const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`; + + try { + // Rotate log if it exceeds max size + await rotateLogIfNeeded(); + await fsp.appendFile(ERROR_LOG_FILE, logLine); + } catch (e) { + console.error('Failed to write to error log', e.message); + } +} + +/** + * Extract request context from Express request object + */ +function extractRequestContext(req) { + if (!req) return {}; + + const clientIP = req.ip || req.socket?.remoteAddress || ''; + + return { + requestId: req.id, + ip: clientIP, + userAgent: req.get('user-agent'), + method: req.method, + path: req.path + }; +} + +/** + * Rotate log file if it exceeds max size + */ +async function rotateLogIfNeeded() { + try { + const stats = await fsp.stat(ERROR_LOG_FILE); + if (stats.size > MAX_ERROR_LOG_SIZE) { + const rotated = ERROR_LOG_FILE + '.1'; + if (await exists(rotated)) { + await fsp.unlink(rotated); + } + await fsp.rename(ERROR_LOG_FILE, rotated); + } + } catch (_) { + // File may not exist yet, that's fine + } +} + +/** + * Return a safe error message to the client without leaking internals + */ +function safeErrorMessage(error) { + const msg = error.message || String(error); + + // Detect port conflict errors from Docker + const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/); + if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) { + const port = portMatch ? portMatch[1] : 'requested'; + return `Port ${port} is already in use. Please choose a different port or stop the conflicting service.`; + } + + // Detect container not found errors + if (msg.includes('No such container')) { + return 'Container not found'; + } + + // Detect network errors + if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT')) { + return 'Service unavailable'; + } + + // Generic safe message for unknown errors + if (process.env.NODE_ENV === 'production') { + return 'An error occurred. Please try again or contact support.'; + } + + // In development, show the actual error + return msg; +} + +module.exports = { + logError, + safeErrorMessage +}; diff --git a/dashcaddy-api/response-helpers.js b/dashcaddy-api/response-helpers.js new file mode 100644 index 0000000..a76777c --- /dev/null +++ b/dashcaddy-api/response-helpers.js @@ -0,0 +1,114 @@ +// Response Helpers +// Standardize API response format across all routes + +const { HTTP_STATUS } = require('./constants'); + +/** + * Success response with data + */ +function success(res, data, statusCode = HTTP_STATUS.OK) { + return res.status(statusCode).json({ + success: true, + data + }); +} + +/** + * Success response with message + */ +function successMessage(res, message, statusCode = HTTP_STATUS.OK) { + return res.status(statusCode).json({ + success: true, + message + }); +} + +/** + * Created response (201) + */ +function created(res, data) { + return res.status(HTTP_STATUS.CREATED).json({ + success: true, + data + }); +} + +/** + * No content response (204) + */ +function noContent(res) { + return res.status(HTTP_STATUS.NO_CONTENT).send(); +} + +/** + * Error response + */ +function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) { + return res.status(statusCode).json({ + success: false, + error: message + }); +} + +/** + * Validation error response (400) + */ +function validationError(res, message) { + return res.status(HTTP_STATUS.BAD_REQUEST).json({ + success: false, + error: message + }); +} + +/** + * Unauthorized response (401) + */ +function unauthorized(res, message = 'Unauthorized') { + return res.status(HTTP_STATUS.UNAUTHORIZED).json({ + success: false, + error: message + }); +} + +/** + * Forbidden response (403) + */ +function forbidden(res, message = 'Forbidden') { + return res.status(HTTP_STATUS.FORBIDDEN).json({ + success: false, + error: message + }); +} + +/** + * Not found response (404) + */ +function notFound(res, message = 'Not found') { + return res.status(HTTP_STATUS.NOT_FOUND).json({ + success: false, + error: message + }); +} + +/** + * Conflict response (409) + */ +function conflict(res, message) { + return res.status(HTTP_STATUS.CONFLICT).json({ + success: false, + error: message + }); +} + +module.exports = { + success, + successMessage, + created, + noContent, + error, + validationError, + unauthorized, + forbidden, + notFound, + conflict +}; diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 823135d..8b090f5 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -60,6 +60,7 @@ const auditLogger = require('./audit-logger'); const portLockManager = require('./port-lock-manager'); const dockerSecurity = require('./docker-security'); const authManager = require('./auth-manager'); +const responseHelpers = require('./response-helpers'); const configureMiddleware = require('./middleware'); const { validateStartupConfig, syncHealthCheckerServices } = require('./startup-validator'); const { CSRF_HEADER_NAME } = require('./csrf-protection'); @@ -1960,3 +1961,4 @@ process.on('uncaughtException', (error) => { setTimeout(() => process.exit(1), 1000).unref(); }); +