import * as fs from 'fs'; import * as path from 'path'; import { DyNTS_SingletonServiceBase } from '../../_services/base/singleton.service-base'; import { DyNTS_global_settings } from '../../_collections/global-settings.const'; import { DyNTS_FileLog_Entry } from './_models/file-log-entry.interface'; import { DyNTS_FileLog_ReadOptions, DyNTS_FileLog_ReadResult } from './_models/file-log-read-result.interface'; /** * Default ertekek a file-log config mezokhez. A `file_log` config nelkul * az install() no-op (enabled === false). */ const DEFAULT_LOG_DIR: string = './logs/server'; const DEFAULT_FILENAME_PREFIX: string = 'server-'; const DEFAULT_FILENAME_SUFFIX: string = '.log'; const DEFAULT_MAX_FILE_SIZE_MB: number = 50; const DEFAULT_MAX_FILES: number = 10; const DEFAULT_RETENTION_DAYS: number = 30; const DEFAULT_STRIP_ANSI: boolean = true; const DEFAULT_INCLUDE_STDOUT: boolean = true; const DEFAULT_INCLUDE_STDERR: boolean = true; /** ANSI escape code regex (szin/formazas kodok). */ const ANSI_ESCAPE_REGEX: RegExp = /\x1b\[[0-9;]*[a-zA-Z]/g; /** Max sor szam egy `readLogFile()` hivasra — vedi a memory-t nagy fajloknal. */ const MAX_READ_LINES: number = 10000; /** Default tail sor szam (ha semmi mas mod nincs megadva). */ const DEFAULT_READ_TAIL: number = 200; /** * File-based szerver log service — duplikalja a stdout/stderr-t per-session * log fajl(ok)ba, miközben a console kimenet erintetlenul marad. Az * in-memory ring buffer (`DyNTS_Logs_Service`) MELLETT mukodik, NEM * helyette. * * Architecture: * - `process.stdout.write` es `process.stderr.write` monkey-patch (NEM * `console.*` — azt mar a `DyNTS_Logs_Service` patch-elheti) * - Per-session fajl: `{logDir}/{prefix}YYYY-MM-DD_HH-MM-SS.log` * - Sync `appendFileSync` iras — crash-safe, azonnali flush * - Rotation: `maxFileSizeMb` atlepesenel uj session fajl * - Retention: `maxFiles` (count) ES `retentionDays` (age) — ami elobb * teljesul; install-kor + minden rotation utan futtatva * - Silent failure: file IO hibak ELNYELODNEK (a file logger SOHASEM * buktathatja el a szervert) * * Idempotens: tobbszori install() hivas no-op (a masodiktol). * * Hasznalat: * DyNTS_global_settings.log_settings.file_log = { enabled: true, ... }; * DyNTS_FileLog_Service.getInstance().install(); // szerver startup */ export class DyNTS_FileLog_Service extends DyNTS_SingletonServiceBase { static getInstance(): DyNTS_FileLog_Service { return DyNTS_FileLog_Service.getSingletonInstance() as DyNTS_FileLog_Service; } private installed: boolean = false; private activeLogDir: string = ''; private filenamePrefix: string = DEFAULT_FILENAME_PREFIX; private maxFileSizeBytes: number = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; private maxFiles: number = DEFAULT_MAX_FILES; private retentionDays: number = DEFAULT_RETENTION_DAYS; private stripAnsiEnabled: boolean = DEFAULT_STRIP_ANSI; private currentLogPath: string = ''; private currentLogSizeBytes: number = 0; private originalStdoutWrite: typeof process.stdout.write | null = null; private originalStderrWrite: typeof process.stderr.write | null = null; /** * Telepiti a file logger-t a `DyNTS_global_settings.log_settings.file_log` * config alapjan. Ha `enabled === false` vagy hianyzik a config → no-op. * * Idempotens — masodszori hivas no-op (akkor is, ha kozben a config * valtozott; a service uj install-jara teardownFor Testing() + install()). */ install(): void { if (this.installed) { return; } const config = DyNTS_global_settings.log_settings?.file_log; if (!config || !config.enabled) { return; } // Config feldolgozas + default-ok this.activeLogDir = config.logDir ?? DEFAULT_LOG_DIR; this.filenamePrefix = config.filenamePrefix ?? DEFAULT_FILENAME_PREFIX; this.maxFileSizeBytes = (config.maxFileSizeMb ?? DEFAULT_MAX_FILE_SIZE_MB) * 1024 * 1024; this.maxFiles = config.maxFiles ?? DEFAULT_MAX_FILES; this.retentionDays = config.retentionDays ?? DEFAULT_RETENTION_DAYS; this.stripAnsiEnabled = config.stripAnsi ?? DEFAULT_STRIP_ANSI; const includeStdout: boolean = config.includeStdout ?? DEFAULT_INCLUDE_STDOUT; const includeStderr: boolean = config.includeStderr ?? DEFAULT_INCLUDE_STDERR; // Log dir letrehozasa silently try { if (!fs.existsSync(this.activeLogDir)) { fs.mkdirSync(this.activeLogDir, { recursive: true }); } } catch { // Ha a mkdir bukik (pl. permission denied), no-op — silent failure return; } // Retention cleanup MIELOTT az uj session fajl letrejon (hogy a takaritas // log uzenete ne keruljon a most induló fajlba) this.cleanupOldLogs(); // Uj session fajl path + size init this.currentLogPath = path.join(this.activeLogDir, this.buildSessionFilename()); this.currentLogSizeBytes = 0; // Monkey-patch — az originalokat elmentjük (teardown miatt) if (includeStdout) { this.originalStdoutWrite = process.stdout.write.bind(process.stdout); const self: DyNTS_FileLog_Service = this; // eslint-disable-next-line @typescript-eslint/no-explicit-any process.stdout.write = function patchedStdoutWrite(...args: any[]): boolean { self.teeWrite(args[0]); // eslint-disable-next-line @typescript-eslint/no-explicit-any return (self.originalStdoutWrite as any)(...args); // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; } if (includeStderr) { this.originalStderrWrite = process.stderr.write.bind(process.stderr); const self: DyNTS_FileLog_Service = this; // eslint-disable-next-line @typescript-eslint/no-explicit-any process.stderr.write = function patchedStderrWrite(...args: any[]): boolean { self.teeWrite(args[0]); // eslint-disable-next-line @typescript-eslint/no-explicit-any return (self.originalStderrWrite as any)(...args); // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; } this.installed = true; // Session header — kulcs info egy helyen a fajl tetejen this.teeWrite( `===== DyNTS server log session started at ${new Date().toISOString()} =====\n` + `===== PID: ${process.pid}, Node: ${process.version} =====\n` + `===== Retention: ${this.retentionDays} days, max files: ${this.maxFiles}, max size: ${config.maxFileSizeMb ?? DEFAULT_MAX_FILE_SIZE_MB}MB =====\n\n`, ); } /** * Visszaadja az aktualis log fajl abszolut path-jat (csak akkor letezo, * ha az install() sikeresen lefutott; egyebkent ures string). */ getCurrentLogPath(): string { return this.currentLogPath; } /** * Az aktualis aktiv log fajl basename-je (NEM teljes path). Csak akkor * nem-ures, ha az install() sikeresen lefutott. */ getCurrentLogFilename(): string { if (!this.currentLogPath) { return ''; } return path.basename(this.currentLogPath); } /** * Aktiv log dir abszolut path-ja. Csak akkor nem-ures, ha az install() * sikeresen lefutott. */ getActiveLogDir(): string { return this.activeLogDir; } /** * Aktiv filename prefix (a service config-jabol). Hasznalja a controller * a request-fajlnev whitelist regex epitesehez. */ getFilenamePrefix(): string { return this.filenamePrefix; } /** * Osszes log fajl listazasa az aktiv log dir-bol. Csak a service prefix-evel * matchelo fajlokat veszi (`{prefix}*.log`). * * Rendezes: mtime DESC (legujabb elol). Hibakat csendben elnyel — ha a dir * nem letezik vagy nem olvashato, ures array-t ad vissza. */ listLogFiles(): DyNTS_FileLog_Entry[] { if (!this.activeLogDir || !this.installed) { return []; } const currentBasename: string = this.getCurrentLogFilename(); const entries: DyNTS_FileLog_Entry[] = []; try { const dirContents: string[] = fs.readdirSync(this.activeLogDir); for (const name of dirContents) { if (!name.startsWith(this.filenamePrefix) || !name.endsWith(DEFAULT_FILENAME_SUFFIX)) { continue; } const fullPath: string = path.join(this.activeLogDir, name); try { const stats: fs.Stats = fs.statSync(fullPath); if (!stats.isFile()) { continue; } entries.push({ name: name, sizeBytes: stats.size, mtimeMs: stats.mtimeMs, isCurrent: name === currentBasename, }); } catch { // egy fajlonkenti hiba ne szakitsa meg a listat } } } catch { // dir read hiba → ures listat adunk vissza return []; } // Legujabb elol entries.sort((a: DyNTS_FileLog_Entry, b: DyNTS_FileLog_Entry): number => b.mtimeMs - a.mtimeMs); return entries; } /** * Egy konkret log fajl olvasasa, line-based mode-ban (tail / head / range). * * **Biztonsagi safeguards:** * - Filename whitelist: `{prefix}*.log` mintara matchelo nev kotelezo * - Path resolution `path.resolve(activeLogDir, name)` + `startsWith(activeLogDir)` (path traversal vedelem) * - Sor szam cap: `MAX_READ_LINES` (10000) * * **Hibak:** * - Throw `Error('not installed')` ha a service nincs install-olva * - Throw `Error('invalid filename')` ha a name nem matchel a whitelist-re VAGY kilep az activeLogDir-bol * - Throw `Error('file not found')` ha a fajl nem letezik * * A hivot kell vigyaznia hogy ezeket HTTP status code-okra forditja. */ readLogFile(name: string, options: DyNTS_FileLog_ReadOptions = {}): DyNTS_FileLog_ReadResult { if (!this.installed || !this.activeLogDir) { throw new Error('FileLog service not installed'); } // Whitelist regex: csak a service prefix-evel matchelo fajlnev if (!this.isValidFilename(name)) { throw new Error('invalid filename'); } // Path resolve + traversal check const resolvedPath: string = path.resolve(this.activeLogDir, name); const resolvedDir: string = path.resolve(this.activeLogDir); // path.sep + sufix ellenőrzéssel megelőzzük a hasonló prefixű dir átverést // (pl. '/logs' vs '/logs-other'); a resolvedPath az activeLogDir-en BELUL // kell legyen. if (!resolvedPath.startsWith(resolvedDir + path.sep) && resolvedPath !== resolvedDir) { throw new Error('invalid filename'); } if (!fs.existsSync(resolvedPath)) { throw new Error('file not found'); } const stats: fs.Stats = fs.statSync(resolvedPath); if (!stats.isFile()) { throw new Error('file not found'); } // Olvass + split soroka. UTF-8 encoding alapertelmezett. Nagy fajloknal a // teljes betoltes nem ideal, de a 10000-soros cap es a Stream-based // optimization egy kesobbi iteracio lehet ha kell. const fullContent: string = fs.readFileSync(resolvedPath, 'utf-8'); // Split a sorvegekre — utolso ures stringet kivagjuk ha a fajl // sorvégzodessel zarult (regenerált sor szám hibakerulest celozzuk meg) const allLines: string[] = fullContent.split(/\r?\n/); if (allLines.length > 0 && allLines[allLines.length - 1] === '') { allLines.pop(); } const totalLines: number = allLines.length; // Mod meghatarozasa: range > head > tail let mode: 'tail' | 'head' | 'range'; let startIdx: number; let endIdx: number; // exclusive if (options.rangeStart !== undefined && options.rangeEnd !== undefined) { mode = 'range'; const start1: number = Math.max(1, Math.floor(options.rangeStart)); const end1: number = Math.max(start1, Math.floor(options.rangeEnd)); startIdx = Math.max(0, Math.min(totalLines, start1 - 1)); endIdx = Math.max(startIdx, Math.min(totalLines, end1)); // Cap if (endIdx - startIdx > MAX_READ_LINES) { endIdx = startIdx + MAX_READ_LINES; } } else if (options.head !== undefined) { mode = 'head'; const headN: number = Math.min(Math.max(1, Math.floor(options.head)), MAX_READ_LINES); startIdx = 0; endIdx = Math.min(totalLines, headN); } else { mode = 'tail'; const tailN: number = Math.min(Math.max(1, Math.floor(options.tail ?? DEFAULT_READ_TAIL)), MAX_READ_LINES); startIdx = Math.max(0, totalLines - tailN); endIdx = totalLines; } const lines: string[] = allLines.slice(startIdx, endIdx); return { name: path.basename(resolvedPath), sizeBytes: stats.size, totalLines: totalLines, lines: lines, mode: mode, rangeStart: startIdx + 1, // 1-based, inclusive rangeEnd: Math.max(startIdx, endIdx), // 1-based, inclusive ha lines van; egyebkent 0-szeru }; } /** * Filename whitelist ellenorzes. Csak a service prefix-evel matchelo, * biztonsagos karakterkeszletu fajlnev megengedett. */ private isValidFilename(name: string): boolean { if (typeof name !== 'string' || name.length === 0 || name.length > 256) { return false; } // Path-szeparatorokat kifejezetten tiltjuk (mar a regex is, de gyors check) if (name.includes('/') || name.includes('\\') || name.includes('..')) { return false; } // `{prefix}[\w\-.]+\.log` — szigoru ASCII set const escapedPrefix: string = this.filenamePrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex: RegExp = new RegExp(`^${escapedPrefix}[\\w\\-.]+\\.log$`); return regex.test(name); } /** * Telepitve van-e (csak akkor true, ha az enabled === true es a setup nem bukott). */ isInstalled(): boolean { return this.installed; } /** * Test-only: visszallitja az eredeti stdout/stderr.write-okat, hogy a * specfajlok egymas utan tisztán futhassanak. Production code NE hivja. */ _teardownForTesting(): void { if (this.originalStdoutWrite) { process.stdout.write = this.originalStdoutWrite; this.originalStdoutWrite = null; } if (this.originalStderrWrite) { process.stderr.write = this.originalStderrWrite; this.originalStderrWrite = null; } this.installed = false; this.activeLogDir = ''; this.currentLogPath = ''; this.currentLogSizeBytes = 0; } /** * Session fajl nev: `{prefix}YYYY-MM-DD_HH-MM-SS_RANDOM.log`. Az RANDOM * suffix biztositja a unique nevet ha ugyanazon a masodpercen tobb fajl jon letre * (pl. rotation kozvetlenul install utan, vagy specfajlok gyors egymas utan). */ private buildSessionFilename(): string { const now: Date = new Date(); const year: string = String(now.getFullYear()); const month: string = String(now.getMonth() + 1).padStart(2, '0'); const day: string = String(now.getDate()).padStart(2, '0'); const hour: string = String(now.getHours()).padStart(2, '0'); const minute: string = String(now.getMinutes()).padStart(2, '0'); const second: string = String(now.getSeconds()).padStart(2, '0'); const rand: string = Math.floor(Math.random() * 100000).toString(36); return `${this.filenamePrefix}${year}-${month}-${day}_${hour}-${minute}-${second}_${rand}${DEFAULT_FILENAME_SUFFIX}`; } /** * Egyetlen stdout/stderr iras tee-zese az aktualis log fajlba. * Hibakat csendben elnyeli — a file logger SOHASEM blokkolja a szervert. * Size-trackeli az aktualis fajlt es szukseg szerint rotalja. */ private teeWrite(data: string | Uint8Array): void { try { if (!this.currentLogPath) { return; } const asString: string = typeof data === 'string' ? data : Buffer.from(data).toString('utf-8'); const cleaned: string = this.stripAnsiEnabled ? asString.replace(ANSI_ESCAPE_REGEX, '') : asString; const writeBytes: number = Buffer.byteLength(cleaned, 'utf-8'); fs.appendFileSync(this.currentLogPath, cleaned, 'utf-8'); this.currentLogSizeBytes += writeBytes; // Rotation trigger if (this.currentLogSizeBytes >= this.maxFileSizeBytes) { this.rotate(); } } catch { // szandekosan elnyelve — silent failure } } /** * Uj session fajl letrehozasa (az aktualis lezarodik). Retention cleanup-ot is * vegrehajt, hogy a rotation termeszetesen tisztan tarttsa a log dir-t. */ private rotate(): void { try { this.cleanupOldLogs(); this.currentLogPath = path.join(this.activeLogDir, this.buildSessionFilename()); this.currentLogSizeBytes = 0; } catch { // szandekosan elnyelve } } /** * Retention takaritas: ket szabaly ami ami elobb teljesul: * 1) maxFiles — ha tobb mint N log fajl van, a legregebbieket torli * 2) retentionDays — `retentionDays`-nel idosebb fajlokat torli * * Hibakat csendben elnyel. A torolt fajlokat es az okot NEM logoljuk, hogy * ne kerüljön extra szennyezes a most induló session fajlba. */ private cleanupOldLogs(): void { try { if (!fs.existsSync(this.activeLogDir)) { return; } const cutoffMs: number = Date.now() - this.retentionDays * 24 * 60 * 60 * 1000; const entries: string[] = fs.readdirSync(this.activeLogDir); type FileInfo = { fullPath: string; mtimeMs: number }; const matchingFiles: FileInfo[] = []; for (const entry of entries) { if (!entry.startsWith(this.filenamePrefix) || !entry.endsWith(DEFAULT_FILENAME_SUFFIX)) { continue; } const fullPath: string = path.join(this.activeLogDir, entry); try { const stats: fs.Stats = fs.statSync(fullPath); matchingFiles.push({ fullPath: fullPath, mtimeMs: stats.mtimeMs }); } catch { // fajlonkent elnyelve } } // Sort: legujabb elol -> legregebbi hatul (csokkeno mtime) matchingFiles.sort((a: FileInfo, b: FileInfo): number => b.mtimeMs - a.mtimeMs); for (let i: number = 0; i < matchingFiles.length; i++) { const file: FileInfo = matchingFiles[i]; const tooOldByAge: boolean = file.mtimeMs < cutoffMs; const tooManyByCount: boolean = i >= this.maxFiles; if (tooOldByAge || tooManyByCount) { try { fs.unlinkSync(file.fullPath); } catch { /* fajlonkent elnyelve */ } } } } catch { // szandekosan elnyelve } } }