dedpaste
Version:
CLI pastebin application using Cloudflare Workers and R2
321 lines (284 loc) • 9.1 kB
JavaScript
// Advanced logging system for dedpaste
import fs from 'fs';
import path from 'path';
import { promises as fsPromises } from 'fs';
import { homedir } from 'os';
// Log levels
const LOG_LEVELS = {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
TRACE: 4
};
// Log level names for display
const LOG_LEVEL_NAMES = {
[LOG_LEVELS.ERROR]: 'ERROR',
[LOG_LEVELS.WARN]: 'WARN',
[LOG_LEVELS.INFO]: 'INFO',
[LOG_LEVELS.DEBUG]: 'DEBUG',
[LOG_LEVELS.TRACE]: 'TRACE'
};
// Default configuration
let config = {
level: LOG_LEVELS.INFO,
logToConsole: true,
logToFile: false,
logDir: path.join(homedir(), '.dedpaste', 'logs'),
logFile: 'dedpaste.log',
maxLogSize: 5 * 1024 * 1024, // 5MB
maxLogFiles: 5,
redactSecrets: true
};
// Private logger state
let initialized = false;
/**
* Initialize the logger
* @param {Object} options - Configuration options
* @returns {Promise<Object>} - Logger instance
*/
async function initialize(options = {}) {
// Merge options with default config
config = { ...config, ...options };
// Convert string level to numeric level if needed
if (typeof config.level === 'string') {
config.level = getLevelFromString(config.level);
}
// Create log directory if needed
if (config.logToFile) {
try {
await fsPromises.mkdir(config.logDir, { recursive: true });
} catch (error) {
console.error(`Failed to create log directory: ${error.message}`);
config.logToFile = false;
}
}
initialized = true;
return {
error: (message, context = {}) => log(LOG_LEVELS.ERROR, message, context),
warn: (message, context = {}) => log(LOG_LEVELS.WARN, message, context),
info: (message, context = {}) => log(LOG_LEVELS.INFO, message, context),
debug: (message, context = {}) => log(LOG_LEVELS.DEBUG, message, context),
trace: (message, context = {}) => log(LOG_LEVELS.TRACE, message, context),
setLevel: (level) => {
if (typeof level === 'string') {
config.level = getLevelFromString(level);
} else {
config.level = level;
}
},
getLevel: () => LOG_LEVEL_NAMES[config.level],
enableFileLogging: async () => {
config.logToFile = true;
await fsPromises.mkdir(config.logDir, { recursive: true });
},
disableFileLogging: () => {
config.logToFile = false;
},
setLogFile: (filePath) => {
config.logFile = filePath;
}
};
}
/**
* Convert string log level to numeric value
* @param {string} levelStr - Log level string
* @returns {number} - Numeric log level
*/
function getLevelFromString(levelStr) {
const upperLevel = levelStr.toUpperCase();
return LOG_LEVELS[upperLevel] !== undefined
? LOG_LEVELS[upperLevel]
: LOG_LEVELS.INFO;
}
/**
* Log a message at the specified level
* @param {number} level - Log level
* @param {string} message - Log message
* @param {Object} context - Additional context
* @returns {boolean} - Success
*/
async function log(level, message, context = {}) {
// Check if logger is initialized
if (!initialized) {
await initialize();
}
// Check if this level should be logged
if (level > config.level) {
return false;
}
// Format timestamp
const timestamp = new Date().toISOString();
// Format log entry
const logEntry = {
timestamp,
level: LOG_LEVEL_NAMES[level],
message,
...context
};
// Redact secrets if enabled
if (config.redactSecrets) {
redactSecrets(logEntry);
}
// Stringify log entry
const logText = JSON.stringify(logEntry);
// Log to console if enabled
if (config.logToConsole) {
// Format for console with colors
let consoleMethod;
let logPrefix;
switch (level) {
case LOG_LEVELS.ERROR:
consoleMethod = console.error;
logPrefix = '\x1b[31mERROR\x1b[0m'; // Red
break;
case LOG_LEVELS.WARN:
consoleMethod = console.warn;
logPrefix = '\x1b[33mWARN\x1b[0m'; // Yellow
break;
case LOG_LEVELS.INFO:
consoleMethod = console.info;
logPrefix = '\x1b[32mINFO\x1b[0m'; // Green
break;
case LOG_LEVELS.DEBUG:
consoleMethod = console.debug;
logPrefix = '\x1b[36mDEBUG\x1b[0m'; // Cyan
break;
case LOG_LEVELS.TRACE:
consoleMethod = console.log;
logPrefix = '\x1b[35mTRACE\x1b[0m'; // Magenta
break;
default:
consoleMethod = console.log;
logPrefix = `[${LOG_LEVEL_NAMES[level]}]`;
}
// Create a simplified format for console
consoleMethod(`${logPrefix} ${timestamp}: ${message}`);
// Log context separately if it exists and isn't empty
const contextKeys = Object.keys(context).filter(key => key !== 'message' && key !== 'level');
if (contextKeys.length > 0) {
const contextObj = {};
contextKeys.forEach(key => {
contextObj[key] = context[key];
});
consoleMethod(contextObj);
}
}
// Log to file if enabled
if (config.logToFile) {
try {
const logFilePath = path.join(config.logDir, config.logFile);
// Check if log rotation is needed
await rotateLogFileIfNeeded(logFilePath);
// Append to log file
await fsPromises.appendFile(logFilePath, logText + '\n');
} catch (error) {
if (config.logToConsole) {
console.error(`Failed to write to log file: ${error.message}`);
}
return false;
}
}
return true;
}
/**
* Rotate log file if it exceeds the maximum size
* @param {string} logFilePath - Path to log file
* @returns {Promise<boolean>} - Whether rotation was performed
*/
async function rotateLogFileIfNeeded(logFilePath) {
try {
// Check if file exists
try {
await fsPromises.access(logFilePath);
} catch (error) {
// File doesn't exist, no rotation needed
return false;
}
// Check file size
const stats = await fsPromises.stat(logFilePath);
if (stats.size < config.maxLogSize) {
return false;
}
// Rotate logs
for (let i = config.maxLogFiles - 1; i > 0; i--) {
const oldFile = `${logFilePath}.${i}`;
const newFile = `${logFilePath}.${i + 1}`;
try {
await fsPromises.access(oldFile);
if (i === config.maxLogFiles - 1) {
// Delete the oldest log file
await fsPromises.unlink(oldFile);
} else {
// Rename to next number
await fsPromises.rename(oldFile, newFile);
}
} catch (error) {
// File doesn't exist, ignore
}
}
// Rename current log file
await fsPromises.rename(logFilePath, `${logFilePath}.1`);
return true;
} catch (error) {
if (config.logToConsole) {
console.error(`Failed to rotate log file: ${error.message}`);
}
return false;
}
}
/**
* Redact sensitive information from log entries
* @param {Object} logEntry - Log entry to redact
*/
function redactSecrets(logEntry) {
// Patterns to redact (keys are patterns, values are replacement text)
const patterns = {
// Private key contents
'-----BEGIN (PRIVATE|RSA PRIVATE) KEY-----[\\s\\S]*?-----END (PRIVATE|RSA PRIVATE) KEY-----': '[REDACTED PRIVATE KEY]',
// PGP private key blocks
'-----BEGIN PGP PRIVATE KEY BLOCK-----[\\s\\S]*?-----END PGP PRIVATE KEY BLOCK-----': '[REDACTED PGP PRIVATE KEY]',
// Passwords and passphrases
'passphrase\\s*[:=]\\s*[\'"][^\'"]*[\'"]': 'passphrase: [REDACTED]',
'password\\s*[:=]\\s*[\'"][^\'"]*[\'"]': 'password: [REDACTED]',
// API tokens and keys
'api[_-]?key\\s*[:=]\\s*[\'"][^\'"]*[\'"]': 'api_key: [REDACTED]',
'token\\s*[:=]\\s*[\'"][^\'"]*[\'"]': 'token: [REDACTED]',
// Encryption keys and IVs in their raw or encoded form
'encryptedKey\\s*[:=]\\s*[\'"][^\'"]*[\'"]': 'encryptedKey: [REDACTED]',
'key\\s*[:=]\\s*[\'"][A-Za-z0-9+/=]{16,}[\'"]': 'key: [REDACTED]'
};
// Helper function to recursively redact objects
function redactObject(obj) {
if (!obj || typeof obj !== 'object') return;
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string') {
// Apply all redaction patterns
let redactedValue = value;
for (const [pattern, replacement] of Object.entries(patterns)) {
redactedValue = redactedValue.replace(new RegExp(pattern, 'gi'), replacement);
}
obj[key] = redactedValue;
} else if (typeof value === 'object') {
// Recursive redaction for nested objects
redactObject(value);
}
}
}
// Apply redaction to log message
if (typeof logEntry.message === 'string') {
let redactedMessage = logEntry.message;
for (const [pattern, replacement] of Object.entries(patterns)) {
redactedMessage = redactedMessage.replace(new RegExp(pattern, 'gi'), replacement);
}
logEntry.message = redactedMessage;
}
// Apply redaction to the whole log entry
redactObject(logEntry);
}
export {
LOG_LEVELS,
LOG_LEVEL_NAMES,
initialize,
log
};