/* * The Peacock Project - a HITMAN server replacement. * Copyright (C) 2021-2023 The Peacock Project Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import type { NextFunction, Response } from "express" import type { RequestWithJwt } from "./types/types" import picocolors from "picocolors" import winston from "winston" import "winston-daily-rotate-file" const isDebug = ["*", "true", "peacock", "yes"].includes( process.env.DEBUG || "false", ) const isTest = ["*", "true", "peacock", "yes"].includes( process.env.TEST || "false", ) /** * Represents the different log levels. */ export enum LogLevel { /** * For errors. Displays in red. */ ERROR = "error", /** * For warnings. Displays in yellow. */ WARN = "warn", /** * For information. Displays in blue. * This is also the fallback for invalid log level values. */ INFO = "info", /** * For debugging. Displays in light blue. */ DEBUG = "debug", /** * For outputting stacktraces. */ TRACE = "trace", /** * For extremely verbose purposes. */ SILLY = "silly", } /** * Represents the different internal log categories used by Peacock. */ export enum LogCategory { /** * Remove the category from the log */ NONE = "none", /** * Set the category to the name of the calling function */ CALLER = "caller", /** * Used for logging HTTP request */ HTTP = "http", } const LOG_LEVEL_NONE = "none" // NOTE: Tests run in "strict mode", so only enable CALLER by default for debug-mode. const LOG_CATEGORY_DEFAULT = isDebug && !isTest ? LogCategory.CALLER : LogCategory.NONE const fileLogLevel = process.env.LOG_LEVEL_FILE || LogLevel.SILLY const consoleLogLevel = process.env.LOG_LEVEL_CONSOLE || LogLevel.SILLY const disabledLogCategories = process.env.LOG_CATEGORY_DISABLED?.split(",") || [] const transports = [] if (fileLogLevel !== LOG_LEVEL_NONE) { const fileTransport = new winston.transports.DailyRotateFile({ filename: "logs/peacock-%DATE%.json", datePattern: "YYYYMMDDHHmmss", frequency: "1d", maxFiles: process.env.LOG_MAX_FILES, level: fileLogLevel, format: winston.format.printf((info) => { return JSON.stringify(info.data) }), }) transports.push(fileTransport) } if (consoleLogLevel !== LOG_LEVEL_NONE) { const consoleTransport = new winston.transports.Console({ level: consoleLogLevel, format: winston.format.combine( winston.format((info) => { if ( !info.data.category || !disabledLogCategories.includes(info.data.category) ) { return info } return false })(), winston.format.printf((info) => { if (info.data.stack) { return `${info.message}\n${info.data.stack}` } return info.message }), ), }) transports.push(consoleTransport) } const winstonLogLevel = {} Object.values(LogLevel).forEach((e, i) => (winstonLogLevel[e] = i)) const logger = winston.createLogger({ levels: winstonLogLevel, transports: transports, }) /** * Adds leading zeros to a number so that the length of the string will always * be the number of places specified. * * @param num The number. * @param places The intended width of the number (character count). * @example * zeroPad(5, 2) // -> "05" */ const zeroPad = (num: string | number, places: number) => String(num).padStart(places, "0") /** * Outputs all given arguments as a debug level indented JSON-message to the console. * * @param args The values to log. */ export function logDebug(...args: unknown[]): void { log(LogLevel.DEBUG, JSON.stringify(args, undefined, " ")) } /** * Outputs a log message to the console. * * @param level The message's level. * @param data The data to output. * @param category The message's category. * @see LogLevel * * @function log */ export function log( level: LogLevel, data: string | unknown, category: LogCategory | string = LOG_CATEGORY_DEFAULT, ): void { if (category === LogCategory.CALLER) { category = log.caller?.name.toString() || "unknown" } const message = data || "No message specified" const now = new Date() const stampParts: number[] = [ now.getHours(), now.getMinutes(), now.getSeconds(), ] const millis = zeroPad(now.getMilliseconds(), 3) const timestamp = `${stampParts .map((part) => zeroPad(part, 2)) .join(":")}:${millis}` const timestampColored = picocolors.gray(timestamp) const categoryColored = picocolors.gray(category) let levelString: string let levelStringColored: string let stack = undefined switch (level) { case LogLevel.ERROR: levelString = "Error" levelStringColored = picocolors.red(levelString) break case LogLevel.WARN: levelString = "Warn" levelStringColored = picocolors.yellow(levelString) break case LogLevel.INFO: default: levelString = "Info" levelStringColored = picocolors.blue(levelString) break case LogLevel.DEBUG: levelString = "Debug" levelStringColored = picocolors.blueBright(levelString) break case LogLevel.TRACE: levelString = "Trace" levelStringColored = picocolors.bgYellow(levelString) stack = new Error("Trace").stack break case LogLevel.SILLY: levelString = "Silly" levelStringColored = picocolors.bgMagenta(levelString) break } const categoryAndLevel = category === LogCategory.NONE ? levelStringColored : `${levelStringColored} | ${categoryColored}` logger.log( level, `[${timestampColored}] [${categoryAndLevel}] ${message}`, { data: { timestamp: timestamp, category: category, level: levelString, message: message, stack: stack, }, }, ) } /** * Express middleware that logs all requests and their details with the info log level. * * @param req The Express request object. * @param res The Express response object. * @param next The Express next function. * @see LogLevel.INFO */ export function loggingMiddleware( req: RequestWithJwt, res: Response, next?: NextFunction, ): void { log( LogLevel.INFO, `${picocolors.green(req.method)} ${picocolors.underline(req.url)}`, LogCategory.HTTP, ) next?.() } export function requestLoggingMiddleware( req: RequestWithJwt, res: Response, next?: NextFunction, ): void { res.once("finish", () => { const debug = { method: req.method, url: req.url, body: req.body, statusCode: res.statusCode, statusMessage: res.statusMessage, } log(LogLevel.DEBUG, JSON.stringify(debug), LogCategory.HTTP) }) next?.() } export function errorLoggingMiddleware( err: Error, req: RequestWithJwt, res: Response, next?: NextFunction, ): void { const debug = { method: req.method, url: req.url, body: req.body, error: `${err.name} - ${err.message} - ${ err.cause || "Unknown cause" }\n${err.stack || "No stack"}`, } log( LogLevel.ERROR, `${picocolors.green(req.method)} ${picocolors.underline( req.url, )} gave an unexpected error! Please see log for details.`, LogCategory.HTTP, ) log(LogLevel.DEBUG, JSON.stringify(debug), LogCategory.HTTP) next?.() }