Compare commits

..
1 Commits
Author SHA1 Message Date
Hermes 11cfb8c26a Consolidate response helpers and error logger to single modules
CI / Security audit (push) Has been cancelled
CI / Test & Lint (push) Has been cancelled
Two cleanups in one pass for the v1.14.0 'works on any platform' theme:

1. Response helpers — merged src/utils/responses.js and the root-level
   response-helpers.js into a single module at src/utils/responses.js.
   The old module had a richer set (created, noContent, validationError,
   unauthorized, forbidden, notFound, conflict) and is now re-exported
   from the new location. Updated 15 routes to import from
   src/utils/responses and deleted the root response-helpers.js.

2. Error logger — error-handler.js now uses the unified
   src/utils/logging.js#logError (same one src/app.js uses), so all errors
   go to one log file with one rotation policy. Removed the dead
   asyncHandler export (the real one is in src/utils/async-handler.js
   and is used everywhere). Deleted the legacy error-logger.js.

Both are invisible to users — same HTTP response shapes, same log file
path, same error format. Internal-only refactor.
2026-06-10 21:37:55 -07:00
22 changed files with 167 additions and 318 deletions
+13 -20
View File
@@ -1,8 +1,18 @@
jest.mock('../error-logger', () => ({ // Mock the unified logging module so we can verify logError is called
logError: jest.fn(), // without writing to the actual error.log file
jest.mock('../src/utils/logging', () => ({
logError: jest.fn().mockResolvedValue(),
safeErrorMessage: jest.fn((err) => {
if (!err) return 'An internal error occurred';
return err.message || String(err);
}),
createLogger: jest.fn(() => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn()
})),
LOG_LEVELS: { debug: 0, info: 1, warn: 2, error: 3 }
})); }));
const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler'); const { errorMiddleware, notFoundHandler } = require('../error-handler');
const { const {
AppError, AppError,
ValidationError, ValidationError,
@@ -30,23 +40,6 @@ describe('Error Handler', () => {
next = jest.fn(); next = jest.fn();
}); });
describe('asyncHandler', () => {
it('calls the wrapped function', async () => {
const fn = jest.fn().mockResolvedValue();
const wrapped = asyncHandler(fn);
await wrapped(req, res, next);
expect(fn).toHaveBeenCalledWith(req, res, next);
});
it('calls next(err) on rejected promise', async () => {
const error = new Error('async fail');
const fn = jest.fn().mockRejectedValue(error);
const wrapped = asyncHandler(fn);
await wrapped(req, res, next);
expect(next).toHaveBeenCalledWith(error);
});
});
describe('errorMiddleware', () => { describe('errorMiddleware', () => {
it('returns 400 for ValidationError', () => { it('returns 400 for ValidationError', () => {
const err = new ValidationError('bad input', 'email'); const err = new ValidationError('bad input', 'email');
@@ -34,7 +34,7 @@ jest.mock('../../pagination', () => ({
parsePaginationParams: jest.fn(() => null), parsePaginationParams: jest.fn(() => null),
})); }));
jest.mock('../../response-helpers', () => ({ jest.mock('../../src/utils/responses', () => ({
success: jest.fn((res, data, statusCode = 200) => { success: jest.fn((res, data, statusCode = 200) => {
return res.status(statusCode).json({ success: true, ...data }); return res.status(statusCode).json({ success: true, ...data });
}), }),
+19 -16
View File
@@ -1,34 +1,38 @@
/** /**
* DashCaddy Error Handler Middleware * DashCaddy Error Handler Middleware
* Centralizes error handling logic to eliminate duplicate catch blocks * Centralizes error handling logic to eliminate duplicate catch blocks
*
* Logging: this middleware uses the unified logError from src/utils/logging.js
* (same one src/app.js uses), so all errors go to one log file. The legacy
* ./error-logger.js and its ./error.log file have been retired.
*/ */
const path = require('path');
const { AppError } = require('./errors'); const { AppError } = require('./errors');
const { logError } = require('./error-logger'); const { LIMITS } = require('./constants');
const { logError: unifiedLogError, safeErrorMessage } = require('./src/utils/logging');
/** const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
* Async route handler wrapper const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
* Automatically 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);
};
}
/** /**
* Global error handling middleware * Global error handling middleware
* MUST be registered after all routes in server.js * MUST be registered after all routes in server.js
*/ */
function errorMiddleware(err, req, res, next) { function errorMiddleware(err, req, res, next) {
// Log all errors with request context // Log all errors with request context (unified, same file the rest of the app uses)
logError(req.path, err, { unifiedLogError(
ERROR_LOG_FILE,
MAX_ERROR_LOG_SIZE,
req.path,
err,
{
method: req.method, method: req.method,
ip: req.ip, ip: req.ip,
userId: req.user?.id, userId: req.user?.id,
body: req.body body: req.body
}); }
).catch(e => console.error('Failed to write to error log:', e.message));
// Determine if this is an operational error (AppError) or programming error // Determine if this is an operational error (AppError) or programming error
const isOperational = err.isOperational || err instanceof AppError; const isOperational = err.isOperational || err instanceof AppError;
@@ -42,7 +46,7 @@ function errorMiddleware(err, req, res, next) {
// Build response // Build response
const response = { const response = {
success: false, success: false,
error: isOperational ? err.message : 'Internal server error', error: isOperational ? safeErrorMessage(err) : 'Internal server error',
code code
}; };
@@ -81,7 +85,6 @@ function notFoundHandler(req, res, next) {
} }
module.exports = { module.exports = {
asyncHandler,
errorMiddleware, errorMiddleware,
notFoundHandler notFoundHandler
}; };
-135
View File
@@ -1,135 +0,0 @@
// 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
};
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dashcaddy-api", "name": "dashcaddy-api",
"version": "1.13.1", "version": "1.13.2",
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
-114
View File
@@ -1,114 +0,0 @@
// 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
};
+1 -1
View File
@@ -8,7 +8,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
/** /**
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
+1 -1
View File
@@ -8,7 +8,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
/** /**
+1 -1
View File
@@ -2,7 +2,7 @@ const express = require('express');
const { DOCKER } = require('../constants'); const { DOCKER } = require('../constants');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { NotFoundError } = require('../errors'); const { NotFoundError } = require('../errors');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
/** /**
* Containers route factory * Containers route factory
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
/** /**
* Credentials routes factory * Credentials routes factory
+1 -1
View File
@@ -15,7 +15,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { NotFoundError, ValidationError } = require('../errors'); const { NotFoundError, ValidationError } = require('../errors');
/** /**
+1 -1
View File
@@ -4,7 +4,7 @@ const fsp = require('fs').promises;
const validatorLib = require('validator'); const validatorLib = require('validator');
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants'); const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors'); const { ValidationError, AuthenticationError, NotFoundError } = require('../errors');
/** /**
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
+1 -1
View File
@@ -3,7 +3,7 @@ const fs = require('fs');
const fsp = require('fs').promises; const fsp = require('fs').promises;
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
/** /**
* Error logs routes factory * Error logs routes factory
+1 -1
View File
@@ -7,7 +7,7 @@ const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
const { resolveServiceUrl } = require('../url-resolver'); const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
/** /**
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
/** /**
* Monitoring routes factory * Monitoring routes factory
+1 -1
View File
@@ -10,7 +10,7 @@ const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError, NotFoundError, ConflictError } = require('../errors'); const { ValidationError, NotFoundError, ConflictError } = require('../errors');
const { resolveServiceUrl } = require('../url-resolver'); const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers'); const { success, error: errorResponse } = require('../src/utils/responses');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
/** /**
+1 -1
View File
@@ -6,7 +6,7 @@
*/ */
const express = require('express'); const express = require('express');
const { success, error: errorResponse, notFound } = require('../response-helpers'); const { success, error: errorResponse, notFound } = require('../src/utils/responses');
/** /**
* SSL Monitor route factory * SSL Monitor route factory
+1 -1
View File
@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { success } = require('../response-helpers'); const { success } = require('../src/utils/responses');
const { ValidationError, NotFoundError } = require('../errors'); const { ValidationError, NotFoundError } = require('../errors');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
+107 -5
View File
@@ -1,22 +1,124 @@
/** /**
* Response helpers - Standard API response formats * Response helpers - Standard API response formats
*
* Single source of truth for HTTP response shapes across DashCaddy.
* Standard envelope: { success: true, ...data } or { success: false, error: "..." }.
*
* All routes should import from this module do not call res.json/res.status
* directly with the response shape, use these helpers instead.
*/ */
const { HTTP_STATUS } = require('../../constants');
// ── Success helpers ────────────────────────────────────────────
/** /**
* Standard error response * Standard success response. Use this in route handlers.
* Wraps the data object with a `success: true` envelope.
* @param {object} res Express response
* @param {object} [data={}] fields to include in the response body
* @param {number} [statusCode=200] HTTP status code
*/
function ok(res, data = {}, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({ success: true, ...data });
}
/**
* Alias for `ok` prefer `ok` in new code, but kept for code that imports as `success`.
*/
function success(res, data, statusCode) {
return ok(res, data, statusCode);
}
/**
* Success response with a human-readable message field.
* Use when there's no data to return, just confirmation.
*/
function successMessage(res, message, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({ success: true, message });
}
/**
* 201 Created response.
*/
function created(res, data = {}) {
return res.status(HTTP_STATUS.CREATED).json({ success: true, ...data });
}
/**
* 204 No Content response.
*/
function noContent(res) {
return res.status(HTTP_STATUS.NO_CONTENT).send();
}
// ── Error helpers ──────────────────────────────────────────────
/**
* Standard error response. Use this in route handlers.
* @param {object} res Express response
* @param {number} statusCode HTTP status code
* @param {string} message Human-readable error message
* @param {object} [extras={}] additional fields to merge into the response
*/ */
function errorResponse(res, statusCode, message, extras = {}) { function errorResponse(res, statusCode, message, extras = {}) {
return res.status(statusCode).json({ success: false, error: message, ...extras }); return res.status(statusCode).json({ success: false, error: message, ...extras });
} }
/** /**
* Standard success response * Alias for `errorResponse` kept for code that imports as `error`.
*/ */
function ok(res, data = {}) { function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) {
return res.json({ success: true, ...data }); return res.status(statusCode).json({ success: false, error: message });
}
/**
* 400 Bad Request invalid input from the user.
*/
function validationError(res, message) {
return res.status(HTTP_STATUS.BAD_REQUEST).json({ success: false, error: message });
}
/**
* 401 Unauthorized no valid credentials.
*/
function unauthorized(res, message = 'Unauthorized') {
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ success: false, error: message });
}
/**
* 403 Forbidden credentials valid but permission denied.
*/
function forbidden(res, message = 'Forbidden') {
return res.status(HTTP_STATUS.FORBIDDEN).json({ success: false, error: message });
}
/**
* 404 Not Found resource doesn't exist.
*/
function notFound(res, message = 'Not found') {
return res.status(HTTP_STATUS.NOT_FOUND).json({ success: false, error: message });
}
/**
* 409 Conflict request conflicts with current state (e.g. duplicate).
*/
function conflict(res, message) {
return res.status(HTTP_STATUS.CONFLICT).json({ success: false, error: message });
} }
module.exports = { module.exports = {
errorResponse, // Success helpers
ok, ok,
success,
successMessage,
created,
noContent,
// Error helpers
errorResponse,
error,
validationError,
unauthorized,
forbidden,
notFound,
conflict,
}; };