const express = require('express'); const fsp = require('fs').promises; const { exists } = require('../src/utilities/fs-helpers'); const { paginate, parsePaginationParams } = require('../src/utilities/pagination'); const { success } = require('../src/utils/responses'); const { ValidationError } = require('../src/utilities/errors'); // The unified error logger writes entries separated by a long horizontal-rule // line made of U+2500 BOX DRAWINGS LIGHT HORIZONTAL (verified 2026-08-18 // against /opt/dashcaddy/dashcaddy-api/data/error.log on DNS2 — the previous // implementation split on '='.repeat(80), which returned ONE block and // produced ZERO entries for the modal). Anything else got dropped silently. const ENTRY_SEPARATOR_RE = /\n\u2500{20,}\n?/; const ENTRY_HEADER_RE = /^\[([^\]]+)\]\s+\[([A-Z]+)\]\s+(.*?):\s*(.*)$/; const MAX_TAIL = 500; const MAX_TAIL_BYTES = 2 * 1024 * 1024; // never read more than 2 MiB from disk /** * Parse the unified error-log format into structured entries. * Each entry: * [2026-08-16T23:13:14.123Z] [ERR] ctx: message * * request: ... (optional) * context: {...} (optional) * ────────────── (separator) * @param {string} text * @returns {Array<{timestamp:string,level:string,context:string,message:string,details:string|null}>} */ function parseEntries(text) { if (!text) return []; const blocks = text.split(ENTRY_SEPARATOR_RE); const entries = []; for (const block of blocks) { const trimmed = block.replace(/^\n+|\n+$/g, ''); if (!trimmed) continue; const firstLineEnd = trimmed.indexOf('\n'); const firstLine = firstLineEnd === -1 ? trimmed : trimmed.slice(0, firstLineEnd); const rest = firstLineEnd === -1 ? '' : trimmed.slice(firstLineEnd + 1); const m = firstLine.match(ENTRY_HEADER_RE); if (!m) continue; entries.push({ timestamp: m[1], level: m[2], context: m[3], message: m[4], details: rest ? rest.replace(/\n+$/g, '') : null, }); } return entries; } /** * Read the last N bytes of a UTF-8 file safely (so the 4 MiB log doesn't * blow up memory or block the event loop). Splits on the first complete * line boundary after the cut. */ async function readTailBytes(filePath, byteLimit) { const fh = await fsp.open(filePath, 'r'); try { const stat = await fh.stat(); const start = Math.max(0, stat.size - byteLimit); const length = stat.size - start; const buf = Buffer.alloc(length); await fh.read(buf, 0, length, start); let text = buf.toString('utf8'); // If we cut into the middle of a UTF-8 sequence, drop the partial char const partialLead = text.match(/[\uD800-\uDBFF]$/); if (partialLead) text = text.slice(0, -1); // Drop a half first line so we never start mid-entry const nl = text.indexOf('\n'); if (start > 0 && nl !== -1) text = text.slice(nl + 1); return { text, totalSize: stat.size, truncated: start > 0 }; } finally { await fh.close(); } } /** * Error logs routes factory * @param {Object} deps - Explicit dependencies * @param {string} deps.ERROR_LOG_FILE - Path to error log file * @param {Object} deps.auditLogger - Audit logger instance * @param {Function} deps.asyncHandler - Async route handler wrapper * @returns {express.Router} */ module.exports = function({ ERROR_LOG_FILE, auditLogger, asyncHandler }) { const router = express.Router(); // Get error logs // GET /api/v1/error-logs?tail=100&level=ERR // - tail: cap on returned entries (default 100, max 500) // - level: filter by level (ERR/WARN/INFO/DBG) — case-insensitive router.get('/error-logs', asyncHandler(async (req, res) => { if (!await exists(ERROR_LOG_FILE)) { return success(res, { logs: [], totalSize: 0, truncated: false }); } let tailRaw = parseInt(req.query.tail, 10); if (!Number.isFinite(tailRaw) || tailRaw <= 0) tailRaw = 100; const tail = Math.min(tailRaw, MAX_TAIL); const levelFilter = req.query.level ? String(req.query.level).toUpperCase() : null; const { text, totalSize, truncated } = await readTailBytes(ERROR_LOG_FILE, MAX_TAIL_BYTES); let logs = parseEntries(text); if (levelFilter) { logs = logs.filter(e => e.level === levelFilter); } // Newest first; bounded by `tail` logs = logs.slice(-tail).reverse(); success(res, { logs, totalSize, truncated, returned: logs.length }); }, 'error-logs-get')); // Clear error logs router.delete('/error-logs', asyncHandler(async (req, res) => { if (!await exists(ERROR_LOG_FILE)) { return success(res, { message: 'Error logs cleared', cleared: 0 }); } const before = await fsp.stat(ERROR_LOG_FILE).then(s => s.size).catch(() => 0); await fsp.writeFile(ERROR_LOG_FILE, ''); success(res, { message: 'Error logs cleared', clearedBytes: before }); }, 'error-logs-clear')); // Audit log router.get('/audit-logs', asyncHandler(async (req, res) => { const paginationParams = parsePaginationParams(req.query); const action = req.query.action || ''; if (paginationParams) { const entries = await auditLogger.query({ limit: Number.MAX_SAFE_INTEGER, offset: 0, action }); const result = paginate(entries, paginationParams); success(res, { entries: result.data, pagination: result.pagination }); } else { const limit = parseInt(req.query.limit) || 50; const offset = parseInt(req.query.offset) || 0; const entries = await auditLogger.query({ limit, offset, action }); success(res, { entries }); } }, 'audit-log')); router.delete('/audit-logs', asyncHandler(async (req, res) => { await auditLogger.clear(); success(res, { message: 'Audit log cleared' }); }, 'audit-log-clear')); return router; }; // Exported for unit testing module.exports.parseEntries = parseEntries; module.exports.readTailBytes = readTailBytes; module.exports.MAX_TAIL = MAX_TAIL; module.exports.MAX_TAIL_BYTES = MAX_TAIL_BYTES;