From decfb58142bb3a0271520e479ca112f8aceed7fb Mon Sep 17 00:00:00 2001 From: perf3ct Date: Mon, 11 Aug 2025 00:13:50 +0000 Subject: [PATCH] feat(logs): cleanup physical log files after 90 days by default asdf --- apps/server/src/services/log.ts | 89 ++++++++++++++++++- apps/server/src/services/options_init.ts | 1 + packages/commons/src/lib/options_interface.ts | 1 + 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/log.ts b/apps/server/src/services/log.ts index 4d66a3909..5d07f8912 100644 --- a/apps/server/src/services/log.ts +++ b/apps/server/src/services/log.ts @@ -2,9 +2,11 @@ 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 optionService from "./options.js"; if (!fs.existsSync(dataDir.LOG_DIR)) { fs.mkdirSync(dataDir.LOG_DIR, 0o700); @@ -17,6 +19,9 @@ const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +const DEFAULT_RETENTION_DAYS = 90; +const MINIMUM_FILES_TO_KEEP = 7; + let todaysMidnight!: Date; initLogFile(); @@ -27,16 +32,96 @@ function getTodaysMidnight() { return new Date(now.getFullYear(), now.getMonth(), now.getDate()); } +async function cleanupOldLogFiles() { + try { + // Get retention days from environment or options + const envRetention = process.env.TRILIUM_LOG_RETENTION_DAYS; + let retentionDays = DEFAULT_RETENTION_DAYS; + + if (envRetention) { + const parsed = parseInt(envRetention, 10); + if (!isNaN(parsed) && parsed > 0 && parsed <= 3650) { + retentionDays = parsed; + } + } else { + retentionDays = optionService.getOptionInt("logRetentionDays", DEFAULT_RETENTION_DAYS); + } + + 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 path = `${dataDir.LOG_DIR}/trilium-${formatDate()}.log`; + const logPath = `${dataDir.LOG_DIR}/trilium-${formatDate()}.log`; if (logFile) { logFile.end(); + + // Clean up old log files when rotating to a new file + cleanupOldLogFiles().catch(() => { + // Ignore cleanup errors + }); } - logFile = fs.createWriteStream(path, { flags: "a" }); + logFile = fs.createWriteStream(logPath, { flags: "a" }); } function checkDate(millisSinceMidnight: number) { diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index cb05feb2b..4f0592cdd 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -126,6 +126,7 @@ const defaultOptions: DefaultOption[] = [ { name: "disableTray", value: "false", isSynced: false }, { name: "eraseUnusedAttachmentsAfterSeconds", value: "2592000", isSynced: true }, // default 30 days { name: "eraseUnusedAttachmentsAfterTimeScale", value: "86400", isSynced: true }, // default 86400 seconds = Day + { name: "logRetentionDays", value: "90", isSynced: false }, // default 90 days { name: "customSearchEngineName", value: "DuckDuckGo", isSynced: true }, { name: "customSearchEngineUrl", value: "https://duckduckgo.com/?q={keyword}", isSynced: true }, { name: "promotedAttributesOpenInRibbon", value: "true", isSynced: true }, diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts index 1cc6b419f..c3ead692e 100644 --- a/packages/commons/src/lib/options_interface.ts +++ b/packages/commons/src/lib/options_interface.ts @@ -85,6 +85,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions