196 lines
6.6 KiB
TypeScript
196 lines
6.6 KiB
TypeScript
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<LogChunk | TailResult | null> {
|
|
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<TailResult | null> {
|
|
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
|
|
};
|
|
}
|