Files
craftdock/src/services/playerDirectory.js
T
Lucas RayanandClaude Sonnet 5.5 640e52c0c9 Initial commit: CraftDock as of 2026-11-01
The previous GitHub history was lost with the account (the local .git was missing
its refs and most objects), so this repository starts from the existing code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 22:44:39 -03:00

209 lines
8.1 KiB
JavaScript

import fsp from 'node:fs/promises';
import path from 'node:path';
import { PLAYER_NAME_REGEX } from '../adapters/serverAdapter.js';
// A persistent XUID→name directory of players who REALLY accessed the world.
//
// Why not the world files directly? A Bedrock world is a compressed LevelDB that
// stores player data keyed by XUID with NO readable gamertag — the name only ever
// appears in the server log, at login. So we build the mapping from the log, but
// keyed by XUID (the canonical identity: survives renames, never duplicates) and
// persisted here so it outlives log rotation.
//
// "Really accessed" = a `Player Spawned` line. A player rejected by the allowlist
// gets a `Player connected` line but is disconnected BEFORE spawning, so keying on
// spawn is what excludes them (Scenario 1). Because entries are persisted, players
// who joined while the server was public stay listed even after an allowlist is
// later enabled and they're no longer on it (Scenario 2).
//
// File lives at the data root (sibling of allowlist.json) so it's bundled into the
// world export/backup, like the rest of CraftDock's metadata.
const FILE = 'craftdock_players.json';
const LEGACY_HISTORY = 'craftdock_players_history.json';
// Bedrock spawn line, e.g.
// [2026-07-19 02:29:10:123 INFO] Player Spawned: Lucas Guerra xuid: 2535407895138987, pfid: ...
// Gamertags may contain spaces, so capture non-greedily up to " xuid:".
const SPAWN_RE = /Player Spawned:\s*(.+?)\s+xuid:\s*(\d+)/gi;
// Control chars docker interleaves as stream-framing bytes; strip them but keep
// newlines so log lines stay separated. Built from char codes to avoid embedding
// raw control bytes in source.
const CTRL_RE = new RegExp('[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]', 'g');
// Module-level cursor (single server instance): Unix seconds of the last log scan.
// First scan reads a bounded tail; later scans read only new lines via `since`, so
// the 2s status poll never re-parses the whole (ever-growing) container log.
let lastScanSec = 0;
function filePath(dataRoot) {
return path.join(dataRoot, FILE);
}
export async function readDirectory(dataRoot) {
try {
const raw = await fsp.readFile(filePath(dataRoot), 'utf8');
const data = JSON.parse(raw);
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
} catch {
return {};
}
}
async function writeDirectory(dataRoot, dir) {
try {
const file = filePath(dataRoot);
await fsp.writeFile(file, JSON.stringify(dir, null, 2), 'utf8');
await fsp.chmod(file, 0o666).catch(() => {});
} catch { /* best-effort: a failed write just means we re-scan next tick */ }
}
// One-time import of the old name-only history array. Those entries have no XUID,
// so they're keyed `legacy:<name>` and superseded the first time the same name is
// seen spawning with a real XUID (see mergeSpawn).
async function migrateLegacy(dataRoot, dir) {
const legacyPath = path.join(dataRoot, LEGACY_HISTORY);
let names;
try {
names = JSON.parse(await fsp.readFile(legacyPath, 'utf8'));
} catch {
return false;
}
if (!Array.isArray(names)) { await fsp.rm(legacyPath, { force: true }).catch(() => {}); return false; }
const known = new Set(Object.values(dir).map((e) => e.name));
for (const name of names) {
if (typeof name === 'string' && PLAYER_NAME_REGEX.test(name) && !known.has(name)) {
dir[`legacy:${name}`] = { name, xuid: null, firstSeen: null, lastSeen: null };
known.add(name);
}
}
await fsp.rm(legacyPath, { force: true }).catch(() => {});
return true;
}
// Merge one spawn (xuid + current name) into the directory. Returns true if the
// directory changed. Promotes any matching `legacy:<name>` placeholder to the real
// XUID-keyed entry.
function mergeSpawn(dir, xuid, name, nowIso) {
if (!/^\d+$/.test(xuid) || !PLAYER_NAME_REGEX.test(name)) return false;
const legacyKey = `legacy:${name}`;
const hadLegacy = legacyKey in dir;
if (hadLegacy) delete dir[legacyKey];
const existing = dir[xuid];
if (!existing) {
dir[xuid] = { name, xuid, firstSeen: nowIso, lastSeen: nowIso };
return true;
}
// Same identity: refresh the name (rename) and lastSeen.
const renamed = existing.name !== name;
existing.name = name;
existing.lastSeen = nowIso;
if (!existing.xuid) existing.xuid = xuid;
return renamed || hadLegacy;
}
let lastDirectoryUpdateMs = 0;
let cachedDirectoryList = null;
async function scanSpawns(dockerService, sinceSec, logger) {
const spawns = [];
try {
const container = await dockerService.getContainer();
const opts = { stdout: true, stderr: true, follow: false };
if (sinceSec) opts.since = sinceSec;
else opts.tail = 2000;
const buf = await container.logs(opts);
const text = buf.toString('utf8').replace(CTRL_RE, '');
let m;
while ((m = SPAWN_RE.exec(text)) !== null) {
spawns.push({ name: m[1].trim(), xuid: m[2] });
}
SPAWN_RE.lastIndex = 0;
} catch (err) {
logger?.warn('player directory scanSpawns failed', { error: err?.message });
}
return spawns;
}
// For Java, the world already carries name↔uuid in usercache.json (real world
// data), so seed the directory from it — no log scanning needed.
async function seedFromUsercache(dataRoot, dir, nowIso) {
let changed = false;
try {
const cache = JSON.parse(await fsp.readFile(path.join(dataRoot, 'usercache.json'), 'utf8'));
if (Array.isArray(cache)) {
for (const e of cache) {
if (e?.name && e?.uuid && PLAYER_NAME_REGEX.test(e.name) && !dir[e.uuid]) {
dir[e.uuid] = { name: e.name, xuid: e.uuid, firstSeen: nowIso, lastSeen: nowIso };
changed = true;
}
}
}
} catch { /* no usercache (Bedrock, or none yet) */ }
return changed;
}
/**
* Update and return the player directory as a flat, sorted list.
* @returns {Promise<Array<{xuid: string|null, name: string, firstSeen: string|null, lastSeen: string|null}>>}
*/
export async function updatePlayerDirectory({ dataRoot, dockerService, edition, logger, minIntervalMs = 0 }) {
const now = Date.now();
if (minIntervalMs > 0 && cachedDirectoryList && (now - lastDirectoryUpdateMs < minIntervalMs)) {
return cachedDirectoryList;
}
const dir = await readDirectory(dataRoot);
const nowIso = new Date(now).toISOString();
let changed = await migrateLegacy(dataRoot, dir);
if (edition === 'java') {
if (await seedFromUsercache(dataRoot, dir, nowIso)) changed = true;
} else if (dockerService) {
// Capture the cursor BEFORE scanning so lines written during the scan aren't
// lost on the next pass.
const nowSec = Math.floor(now / 1000);
const spawns = await scanSpawns(dockerService, lastScanSec || undefined, logger);
lastScanSec = nowSec;
for (const s of spawns) {
if (mergeSpawn(dir, s.xuid, s.name, nowIso)) changed = true;
}
}
if (changed) await writeDirectory(dataRoot, dir);
cachedDirectoryList = Object.values(dir).sort((a, b) => a.name.localeCompare(b.name));
lastDirectoryUpdateMs = Date.now();
return cachedDirectoryList;
}
/** Resolve an XUID to its current gamertag (Bedrock console commands are name-based). */
export async function resolveName(dataRoot, xuid) {
const dir = await readDirectory(dataRoot);
return dir[xuid]?.name || null;
}
/**
* Server-side paginated + filtered view of the directory. Scales to very large
* directories: the client fetches one page at a time instead of the whole list.
* @returns {Promise<{ items: Array, total: number, page: number, pageSize: number }>}
*/
export async function queryDirectory(dataRoot, { page = 1, pageSize = 25, q = '' } = {}) {
const dir = await readDirectory(dataRoot);
const needle = String(q || '').trim().toLowerCase();
let items = Object.values(dir);
if (needle) items = items.filter((e) => e.name?.toLowerCase().includes(needle));
items.sort((a, b) => a.name.localeCompare(b.name));
const total = items.length;
const size = Math.max(1, pageSize | 0);
const pageCount = Math.max(1, Math.ceil(total / size));
const current = Math.min(Math.max(1, page | 0), pageCount);
const start = (current - 1) * size;
return { items: items.slice(start, start + size), total, page: current, pageSize: size };
}