import { open, stat } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { resolve, sep } from 'node:path'; import { rootOfServer, gameOfServer } from '../config.js'; import { nativeTail, nativeLogChunk } from '../backends/files.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 } export interface LogCursor { fileId: string; offset: number; size: number; modified: string; anchorLength: number; anchorHash: string; } export interface LogChunk { contentBase64: string; size: number; mtime: string; truncated: boolean; cursor: LogCursor; reset: boolean; } /** Bounded byte reads; an anchor before the offset detects copy-truncate and regrowth. */ export async function readLogChunk( path: string, cursor?: LogCursor ): Promise { const remote = await nativeLogChunk(path, cursor); if (remote !== undefined) return remote; let file; try { file = await open(path, 'r'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; } try { const st = await file.stat(); if (!st.isFile()) return null; const fileId = `${st.dev}:${st.ino}`, modified = `${st.mtimeMs}:${st.ctimeMs}`, size = st.size; const mtime = st.mtime.toISOString(); if ( cursor?.fileId === fileId && cursor.modified === modified && cursor.size === size && cursor.offset === size ) { return { contentBase64: '', size, mtime, truncated: cursor.offset > 0, cursor, reset: false }; } const hash = (value: Buffer) => createHash('sha256').update(value).digest('hex'); let reset = !cursor || cursor.fileId !== fileId || cursor.offset > size; if (cursor && !reset) { const anchor = Buffer.alloc(cursor.anchorLength); const read = await file.read(anchor, 0, anchor.length, cursor.offset - anchor.length); reset = read.bytesRead !== anchor.length || hash(anchor) !== cursor.anchorHash; } const start = reset ? Math.max(0, size - READ_WINDOW_BYTES) : cursor!.offset; const content = Buffer.alloc(Math.min(READ_WINDOW_BYTES, size - start)); const read = await file.read(content, 0, content.length, start); const offset = start + read.bytesRead; const anchor = Buffer.alloc(Math.min(256, offset)); await file.read(anchor, 0, anchor.length, offset - anchor.length); return { contentBase64: content.subarray(0, read.bytesRead).toString('base64'), size, mtime, truncated: start > 0, reset, cursor: { fileId, modified, size, offset, anchorLength: anchor.length, anchorHash: hash(anchor) } }; } finally { await file.close(); } } 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 { const remote = await nativeTail(path, lines); if (remote !== undefined) return remote; 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 }; }