Files
electric-cherry/src/lib/server/fleet/logs.ts
T

105 lines
3.6 KiB
TypeScript

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<TailResult | null> {
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
};
}