import { open, stat } from 'node:fs/promises'; import { resolve, sep } from 'node:path'; import { rootOfServer, gameOfServer } from '../config.js'; // Whitelisted log files exposed to the UI, mapped to their path relative // to each server's root directory. Adding a new supported log = one entry // here + one tab in LogPanel.svelte. export const KNOWN_LOGS = { console: 'logs/console.log', games_sp: 'logs/games_sp.log', games_zm: 'logs/games_zm.log' } as const; export type LogKey = keyof typeof KNOWN_LOGS; export const gameLogKey = (server: string): LogKey => gameOfServer(server) === 't6' ? 'games_zm' : 'games_sp'; export function isLogKey(v: string): v is LogKey { return Object.prototype.hasOwnProperty.call(KNOWN_LOGS, v); } /** * Resolve a per-server log path safely. Two guards: * 1. The server directory itself must be a real subdirectory of * SERVERS_ROOT — this catches `serverName = ".."` and similar. * 2. The final target path must be inside that server directory — this * catches escapes in the log filename (defensive; KNOWN_LOGS entries * are hardcoded but keep the check for future additions). * Returns null on any escape. */ export function resolveLogPath(serverName: string, key: LogKey): string | null { const serversRoot = resolve(rootOfServer(serverName)); const serverDir = resolve(serversRoot, serverName); if (serverDir === serversRoot || !serverDir.startsWith(serversRoot + sep)) { return null; } const target = resolve(serverDir, KNOWN_LOGS[key]); if (target !== serverDir && !target.startsWith(serverDir + sep)) { return null; } return target; } export interface TailResult { lines: string[]; size: number; mtime: string; // ISO truncated: boolean; // true when we didn't read from the start of the file } const DEFAULT_TAIL_LINES = 200; const MAX_TAIL_LINES = 2000; const READ_WINDOW_BYTES = 256 * 1024; // 256 KB — plenty for a few hundred lines /** * Read the last N lines of a file efficiently: only slurps up to 256 KB * from the end of the file, splits on newlines, drops the (potentially * partial) first line if we didn't start at byte 0. * * Returns null when the file doesn't exist yet — treat this as "log * hasn't been written to yet" rather than an error. */ export async function tailLog( path: string, lines: number = DEFAULT_TAIL_LINES ): Promise { let st; try { st = await stat(path); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; throw err; } if (!st.isFile()) return null; const wanted = Math.min(Math.max(1, Math.floor(lines)), MAX_TAIL_LINES); const size = st.size; const start = Math.max(0, size - READ_WINDOW_BYTES); const length = size - start; const buf = Buffer.alloc(length); const fd = await open(path, 'r'); try { if (length > 0) await fd.read(buf, 0, length, start); } finally { await fd.close(); } const text = buf.toString('utf8'); let allLines = text.split(/\r?\n/); // If we didn't read from position 0, the first "line" is almost // certainly a mid-line fragment — drop it. if (start > 0 && allLines.length > 0) allLines = allLines.slice(1); // A trailing newline creates an empty final entry — drop it. if (allLines.length > 0 && allLines[allLines.length - 1] === '') { allLines = allLines.slice(0, -1); } return { lines: allLines.slice(-wanted), size, mtime: st.mtime.toISOString(), truncated: start > 0 }; }