"use strict"; import type { Request, Response } from "express"; import fs from "fs"; import path from "path"; import { EOL } from "os"; import dataDir from "./data_dir.js"; import cls from "./cls.js"; import config, { LOGGING_DEFAULT_RETENTION_DAYS } from "./config.js"; if (!fs.existsSync(dataDir.LOG_DIR)) { fs.mkdirSync(dataDir.LOG_DIR, 0o700); } let logFile: fs.WriteStream | undefined; const SECOND = 1000; const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; const MINIMUM_FILES_TO_KEEP = 7; let todaysMidnight!: Date; initLogFile(); function getTodaysMidnight() { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate()); } async function cleanupOldLogFiles() { try { // Get retention days from environment or options let retentionDays = LOGGING_DEFAULT_RETENTION_DAYS; const customRetentionDays = config.Logging.retentionDays; if (customRetentionDays > 0) { retentionDays = customRetentionDays; } else if (customRetentionDays <= -1){ info(`Log cleanup: keeping all log files, as specified by configuration.`); return } const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - retentionDays); // Read all log files const files = await fs.promises.readdir(dataDir.LOG_DIR); const logFiles: Array<{name: string, mtime: Date, path: string}> = []; for (const file of files) { // Security: Only process files matching our log pattern if (!/^trilium-\d{4}-\d{2}-\d{2}\.log$/.test(file)) { continue; } const filePath = path.join(dataDir.LOG_DIR, file); // Security: Verify path stays within LOG_DIR const resolvedPath = path.resolve(filePath); const resolvedLogDir = path.resolve(dataDir.LOG_DIR); if (!resolvedPath.startsWith(resolvedLogDir + path.sep)) { continue; } try { const stats = await fs.promises.stat(filePath); logFiles.push({ name: file, mtime: stats.mtime, path: filePath }); } catch (err) { // Skip files we can't stat } } // Sort by modification time (oldest first) logFiles.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()); // Keep minimum number of files if (logFiles.length <= MINIMUM_FILES_TO_KEEP) { return; } // Delete old files, keeping minimum let deletedCount = 0; for (let i = 0; i < logFiles.length - MINIMUM_FILES_TO_KEEP; i++) { const file = logFiles[i]; if (file.mtime < cutoffDate) { try { await fs.promises.unlink(file.path); deletedCount++; } catch (err) { // Log deletion failed, but continue with others } } } if (deletedCount > 0) { info(`Log cleanup: deleted ${deletedCount} old log files`); } } catch (err) { // Cleanup failed, but don't crash the log rotation } } function initLogFile() { todaysMidnight = getTodaysMidnight(); const logPath = `${dataDir.LOG_DIR}/trilium-${formatDate()}.log`; const isRotating = !!logFile; if (isRotating) { logFile!.end(); } logFile = fs.createWriteStream(logPath, { flags: "a" }); // Clean up old log files when rotating to a new file if (isRotating) { cleanupOldLogFiles().catch(() => { // Ignore cleanup errors }); } } function checkDate(millisSinceMidnight: number) { if (millisSinceMidnight >= DAY) { initLogFile(); millisSinceMidnight -= DAY; } return millisSinceMidnight; } function log(str: string | Error) { const bundleNoteId = cls.get("bundleNoteId"); if (bundleNoteId) { str = `[Script ${bundleNoteId}] ${str}`; } let millisSinceMidnight = Date.now() - todaysMidnight.getTime(); millisSinceMidnight = checkDate(millisSinceMidnight); logFile!.write(`${formatTime(millisSinceMidnight)} ${str}${EOL}`); console.log(str); } function info(message: string | Error) { log(message); } function error(message: string | Error | unknown) { log(`ERROR: ${message}`); } const requestBlacklist = ["/app", "/images", "/stylesheets", "/api/recent-notes"]; function request(req: Request, res: Response, timeMs: number, responseLength: number | string = "?") { for (const bl of requestBlacklist) { if (req.url.startsWith(bl)) { return; } } if (req.url.includes(".js.map") || req.url.includes(".css.map")) { return; } info((timeMs >= 10 ? "Slow " : "") + `${res.statusCode} ${req.method} ${req.url} with ${responseLength} bytes took ${timeMs}ms`); } function pad(num: number) { num = Math.floor(num); return num < 10 ? `0${num}` : num.toString(); } function padMilli(num: number) { if (num < 10) { return `00${num}`; } else if (num < 100) { return `0${num}`; } else { return num.toString(); } } function formatTime(millisSinceMidnight: number) { return `${pad(millisSinceMidnight / HOUR)}:${pad((millisSinceMidnight % HOUR) / MINUTE)}:${pad((millisSinceMidnight % MINUTE) / SECOND)}.${padMilli(millisSinceMidnight % SECOND)}`; } function formatDate() { return `${pad(todaysMidnight.getFullYear())}-${pad(todaysMidnight.getMonth() + 1)}-${pad(todaysMidnight.getDate())}`; } export default { info, error, request };