diff --git a/server/package.json b/server/package.json index 0628a0ba..4975b175 100644 --- a/server/package.json +++ b/server/package.json @@ -15,7 +15,9 @@ "async-mutex": "^0.4.0", "better-sqlite3": "^8.5.0", "kysely": "^0.26.1", - "source-map-support": "^0.5.21" + "source-map-support": "^0.5.21", + "zod": "^3.21.4", + "zod-validation-error": "^1.3.1" }, "devDependencies": { "@types/better-sqlite3": "^7.6.4", diff --git a/server/src/cli.ts b/server/src/cli.ts index adb7b8c0..895690b0 100644 --- a/server/src/cli.ts +++ b/server/src/cli.ts @@ -88,36 +88,36 @@ async function handle_input(input: string): Promise { console.log( 'whitelist_remove_ign - Removes the UUID cached with\n the given IGN from the whitelist, and saves the whitelist to disk', ) - } else if (command === 'whitelist_load') await metadata.whitelist_load() - else if (command === 'whitelist_save') await metadata.whitelist_save() + } else if (command === 'whitelist_load') await metadata.loadWhitelist() + else if (command === 'whitelist_save') await metadata.saveWhitelist() else if (command === 'whitelist_add') { if (extras.length === 0) throw new Error('Did not provide UUID to whitelist') const uuid = extras - await metadata.whitelist_add(uuid) - await metadata.whitelist_save() + metadata.whitelist.add(uuid) + await metadata.saveWhitelist() } else if (command === 'whitelist_add_ign') { if (extras.length === 0) throw new Error('Did not provide UUID to whitelist') const ign = extras - const uuid = metadata.uuid_cache_lookup(ign) + const uuid = metadata.getCachedPlayerUuid(ign) if (uuid == null) throw new Error('No cached UUID for IGN ' + ign) - await metadata.whitelist_add(uuid) - await metadata.whitelist_save() + metadata.whitelist.add(uuid) + await metadata.saveWhitelist() } else if (command === 'whitelist_remove') { if (extras.length === 0) throw new Error('Did not provide UUID to whitelist') const uuid = extras - await metadata.whitelist_remove(uuid) - await metadata.whitelist_save() + metadata.whitelist.delete(uuid) + await metadata.saveWhitelist() } else if (command === 'whitelist_remove_ign') { if (extras.length === 0) throw new Error('Did not provide UUID to whitelist') const ign = extras - const uuid = metadata.uuid_cache_lookup(ign) + const uuid = metadata.getCachedPlayerUuid(ign) if (uuid == null) throw new Error('No cached UUID for IGN ' + ign) - await metadata.whitelist_remove(uuid) - await metadata.whitelist_save() + metadata.whitelist.delete(uuid) + await metadata.saveWhitelist() } else { throw new Error(`Unknown command "${command}"`) } diff --git a/server/src/deps/errors.ts b/server/src/deps/errors.ts new file mode 100644 index 00000000..06acd30d --- /dev/null +++ b/server/src/deps/errors.ts @@ -0,0 +1,49 @@ +import node_os from 'node:os' +import node_utils from 'node:util' + +export enum ErrorType { + FileExists, + FileNotFound, + UNKNOWN, +} + +/** + * Attempts to transform Node's less-than-helpful exceptions into something + * more readable and logic-able. + */ +export function getErrorType(error: any): ErrorType { + switch (Math.abs(error.errno ?? Infinity)) { + case node_os.constants.errno.ENOENT: + return ErrorType.FileNotFound + case node_os.constants.errno.EEXIST: + return ErrorType.FileExists + default: + return ErrorType.UNKNOWN + } +} + +/** + * Utility that guarantees that the error is an instance of Error. + */ +export function ensureError(error: any): Error { + if (error instanceof Error) { + return error + } + switch (typeof error) { + case 'string': + return new Error(error) + case 'number': + case 'bigint': + return new Error(String(error)) + } + return new Error(node_utils.inspect(error)) +} + +/** + * This is useful in cases where you need to throw but can't because of + * Javascript. Read more for context: + * https://www.proposals.es/proposals/throw%20expressions + */ +export function inlineThrow(error: any): T { + throw error +} diff --git a/server/src/deps/json.ts b/server/src/deps/json.ts new file mode 100644 index 00000000..fb3e6faf --- /dev/null +++ b/server/src/deps/json.ts @@ -0,0 +1,16 @@ +export type JSONObject = { [key: string]: JSONValue | undefined } +export type JSONArray = JSONValue[] +export type JSONValue = + | JSONObject + | JSONArray + | string + | number + | boolean + | null + +/** + * Wrapper function for JSON.parse() that provides a proper return type. + */ +export function parse(raw: string): JSONValue { + return JSON.parse(raw) +} diff --git a/server/src/main.ts b/server/src/main.ts index 1dde6b7f..fc4a0883 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,13 +1,25 @@ import './cli' import * as database from './database' -import { uuid_cache_store, getConfig, whitelist_check } from './metadata' +import * as metadata from './metadata' import { ClientPacket } from './protocol' import { CatchupRequestPacket } from './protocol/CatchupRequestPacket' import { ChunkTilePacket } from './protocol/ChunkTilePacket' import { TcpClient, TcpServer } from './server' import { RegionCatchupPacket } from './protocol/RegionCatchupPacket' -database.setup().then(() => new Main()) +let config: metadata.Config = null! +Promise.resolve().then(async () => { + await database.setup() + + config = metadata.getConfig() + + // These two are only used if whitelist is enabled... but best to load them + // anyway lest there be a modification to them that is then saved. + await metadata.loadWhitelist() + await metadata.loadUuidCache() + + new Main() +}) type ProtocolClient = TcpClient // TODO cleanup @@ -20,10 +32,11 @@ export class Main { async handleClientAuthenticated(client: ProtocolClient) { if (!client.uuid) throw new Error('Client not authenticated') - uuid_cache_store(client.mcName!, client.uuid) + metadata.cachePlayerUuid(client.mcName!, client.uuid!) + await metadata.saveUuidCache() - if ((await getConfig()).whitelist) { - if (!whitelist_check(client.uuid)) { + if (config.whitelist) { + if (!metadata.whitelist.has(client.uuid)) { client.log( `Rejected unwhitelisted user ${client.mcName} (${client.uuid})`, ) diff --git a/server/src/metadata.ts b/server/src/metadata.ts index 731ca270..4ce11230 100644 --- a/server/src/metadata.ts +++ b/server/src/metadata.ts @@ -1,165 +1,117 @@ -import lib_fs from 'fs' +import node_fs from 'node:fs' +import node_path from 'node:path' import { Mutex } from 'async-mutex' -import { loadOrSaveDefaultStringFile } from './utilities' - -export const ENOENT = -2 -export const EEXIST = -17 -export enum OsError { - FileExists, - FileNotFound, - UNKNOWN, -} -export function get_os_error(e: any): OsError { - if (typeof e !== 'object') return OsError.UNKNOWN - if (e.errno === ENOENT) return OsError.FileNotFound - if (e.errno === EEXIST) return OsError.FileExists - return OsError.UNKNOWN -} +import * as errors from './deps/errors' +import * as json from './deps/json' +import * as z from 'zod' +import { fromZodError } from 'zod-validation-error' export const DATA_FOLDER = process.env['MAPSYNC_DATA_DIR'] ?? './mapsync' try { - lib_fs.mkdirSync(DATA_FOLDER, { recursive: true }) + node_fs.mkdirSync(DATA_FOLDER, { recursive: true }) console.log(`Created data folder "${DATA_FOLDER}"`) } catch (e: any) { - if (get_os_error(e) !== OsError.FileExists) throw e + if (errors.getErrorType(e) !== errors.ErrorType.FileExists) throw e console.log(`Using data folder "${DATA_FOLDER}"`) } -export const CONFIG_FILE = `${DATA_FOLDER}/config.json` -export const WHITELIST_FILE = `${DATA_FOLDER}/whitelist.json` -export const UUID_CACHE_FILE = `${DATA_FOLDER}/uuid_cache.json` - -/** The config.json file */ -export interface Config { - /** Whether the whitelist is being enforced */ - whitelist: boolean +/** + * Attempts to read a config file within the DATA_FOLDER. If the file isn't + * found then a new file is created with default contents. + * + * @param file The file-name, eg: "config.json" + * @param parser Use this transform and check the raw JSON parsed from the file. (Put your Zod.parse here) + * @param defaultSupplier A function that returns a fully-valid default config for this file. + */ +function parseConfigFile( + file: string, + parser: (raw: json.JSONValue) => T, + defaultSupplier: () => any, +): T { + file = node_path.resolve(DATA_FOLDER, file) + let fileContents: string = null! + try { + fileContents = node_fs.readFileSync(file, 'utf8') + } catch (e) { + if (errors.getErrorType(e) !== errors.ErrorType.FileNotFound) { + throw e + } + // Could not find the config file, so attempt to create a default one + const defaultContent = defaultSupplier() + node_fs.writeFileSync(file, JSON.stringify(defaultContent, null, 2), 'utf8') + return defaultContent + } + try { + return parser(json.parse(fileContents)) + } catch (e) { + if (e instanceof z.ZodError) { + throw 'Could not parse ' + file + ': ' + fromZodError(e) + } + throw e + } } -const default_config: Config = { - //Enable whitelist by default to prevent *tomfoolery* - whitelist: true, +/** + * Convenience function to quickly save a config's contents. + * + * @param file The file-name, eg: "config.json" + * @param content The file's contents, which will be JSON-stringified if it's not already a string. + */ +function saveConfigFile(file: string, content: any) { + file = node_path.resolve(DATA_FOLDER, file) + if (typeof content !== 'string') { + content = JSON.stringify(content, null, 2) + } + node_fs.writeFileSync(file, content, 'utf8') } -// Force initialize -const CONFIG_MUTEX = new Mutex() -let config: Config | null = null -export async function getConfig(): Promise { - return await CONFIG_MUTEX.runExclusive(async () => { - if (config === null) { - const json: any = JSON.parse( - await loadOrSaveDefaultStringFile( - CONFIG_FILE, - JSON.stringify(default_config), - ), - ) - if (typeof json !== 'object') { - throw new Error("Config file wasn't an JSON object") - } - config = json as Config - } - return config - }).catch((e) => { - console.error( - '[Config] An error occurred when attempting to read `config.json`', - ) - console.error(e) - throw e - }) -} +// ------------------------------------------------------------ // +// Config +// ------------------------------------------------------------ // -getConfig() +const CONFIG_FILE = 'config.json' +const CONFIG_SCHEMA = z.object({ + host: z.string().default('0.0.0.0'), + port: z.coerce.number().positive().max(65535).default(12312), + gameAddress: z.string(), + whitelist: z.boolean().default(true), +}) +export type Config = z.infer +export function getConfig(): Config { + return parseConfigFile(CONFIG_FILE, CONFIG_SCHEMA.parse, () => ({ + gameAddress: 'localhost:25565', + whitelist: true, + })) +} // ------------------------------------------------------------ // // Whitelist // ------------------------------------------------------------ // -// A "queue" of sorts so operations don't conflict +const WHITELIST_FILE = 'whitelist.json' const WHITELIST_MUTEX = new Mutex() +const WHITELIST_SCHEMA = z.array(z.string().uuid()) export const whitelist = new Set() -/** Loads the whitelist from whitelist.json */ -export async function whitelist_load() { - WHITELIST_MUTEX.runExclusive(async () => { - const json: any = JSON.parse( - await loadOrSaveDefaultStringFile(WHITELIST_FILE, '[]'), +export async function loadWhitelist() { + await WHITELIST_MUTEX.runExclusive(async () => { + const parsed = parseConfigFile( + WHITELIST_FILE, + WHITELIST_SCHEMA.parse, + () => [], ) - if (!Array.isArray(json)) { - throw new Error("Whitelist file wasn't an array") - } - for (const entry of json as any[]) { - if (typeof entry !== 'string') { - throw new Error(`Entry "${entry}" is not a string`) - } - } whitelist.clear() - for (const entry of json as string[]) { + for (const entry of parsed) { whitelist.add(entry) } console.log('[Whitelist] Loaded whitelist') - }).catch((e) => { - if (get_os_error(e) === OsError.FileNotFound) { - console.error( - '[Whitelist] No whitelist file was found. An empty one will be created shortly.', - ) - whitelist_save() // Don't await, will cause deadlock - } else { - console.error( - '[Whitelist] Error occurred while loading the whitelist from disk', - ) - console.error(e) - console.error('[Whitelist] The whitelist will be loaded as empty') - } }) } -/** Saves the whitelist to whitelist.json */ -export async function whitelist_save() { - WHITELIST_MUTEX.runExclusive(async () => { - await lib_fs.promises.writeFile( - WHITELIST_FILE, - JSON.stringify(Array.from(whitelist)), - ) +export async function saveWhitelist() { + await WHITELIST_MUTEX.runExclusive(async () => { + saveConfigFile(WHITELIST_FILE, JSON.stringify(Array.from(whitelist))) console.log('[Whitelist] Saved whitelist') - }).catch((e) => { - console.error( - '[Whitelist] Error occurred while saving the whitelist to the disk', - ) - console.error(e) - }) -} - -// Load whitelist on startup -whitelist_load() - -/** Checks if the given uuid is in the whitelist */ -export function whitelist_check(uuid: string): boolean { - return whitelist.has(uuid) -} - -// These need to enqueue in whitelist_operation in case they are called in the middle of a load operation -/** Adds the given uuid to the whitelist */ -export async function whitelist_add(uuid: string) { - WHITELIST_MUTEX.runExclusive(async () => { - whitelist.add(uuid) - console.log(`[Whitelist] Added user "${uuid}" to the whitelist`) - }).catch((e) => { - console.error( - `[Whitelist] Error occurred adding user "${uuid}" to the whitelist`, - ) - console.error(e) - }) -} - -/** Removes the given uuid from the whitelist */ -export async function whitelist_remove(uuid: string) { - WHITELIST_MUTEX.runExclusive(async () => { - whitelist.delete(uuid) - console.log(`[Whitelist] Removed user "${uuid}" from the whitelist`) - }).catch((e) => { - console.error( - `[Whitelist] Error occurred removing user "${uuid}" to the whitelist`, - ) - console.error(e) }) } @@ -167,67 +119,41 @@ export async function whitelist_remove(uuid: string) { // UUID Cache // ------------------------------------------------------------ // +const UUID_CACHE_FILE = 'uuid_cache.json' const UUID_CACHE_MUTEX = new Mutex() -/** A cache storing uuids by player IGN */ -export const uuid_cache = new Map() +const UUID_CACHE_SCHEMA = z.record(z.string().uuid()) +// IGN UUID +const uuid_cache = new Map() -/** Saves the UUID cache to uuid_cache.json */ -export async function uuid_cache_save() { - UUID_CACHE_MUTEX.runExclusive(async () => { - await lib_fs.promises.writeFile( - UUID_CACHE_FILE, - JSON.stringify(Array.from(uuid_cache)), - ) - console.log('[UUID Cache] Saved UUID cache') - }).catch((e) => { - console.error( - '[UUID Cache] Error occured while saving the whitelist to the disk', - ) - console.error(e) - }) +export function getCachedPlayerUuid(playerName: string) { + return uuid_cache.get(playerName) ?? null } -export async function uuid_cache_load(): Promise { - UUID_CACHE_MUTEX.runExclusive(async () => { - const json: any = JSON.parse( - await loadOrSaveDefaultStringFile(UUID_CACHE_FILE, '{}'), +export function cachePlayerUuid(playerName: string, playerUUID: string) { + uuid_cache.set(playerName, playerUUID) +} + +export async function loadUuidCache() { + await UUID_CACHE_MUTEX.runExclusive(async () => { + const parsed = parseConfigFile( + UUID_CACHE_FILE, + UUID_CACHE_SCHEMA.parse, + () => ({}), ) - if (typeof json !== 'object') { - throw new Error("UUID cache file wasn't an JSON object") - } uuid_cache.clear() - for (const [key, value] of Object.entries(json)) { + for (const [key, value] of Object.entries(parsed)) { uuid_cache.set(key, String(value)) } - console.log('[UUID Cache] Saved UUID cache') - }).catch((e) => { - if (get_os_error(e) === OsError.FileNotFound) { - console.error( - '[UUID Cache] No uuid cache file was found. A new one will be created shortly.', - ) - uuid_cache_save() // Don't await, will cause deadlock - } else { - console.error( - '[UUID Cache] An error occured when attempting to read `uuid_cache.json`', - ) - console.error(e) - console.error('[UUID Cache] A new uuid cache will be created') - } + console.log('[UUID Cache] Loaded UUID cache') }) } -// Load UUID cache on startup -uuid_cache_load() - -/** Caches a player IGN with their UUID */ -export function uuid_cache_store(ign: string, uuid: string) { - if (uuid == null || ign == null) return - uuid_cache.set(ign, uuid) - console.log(`[UUID Cache] cached "${ign}" as UUID "${uuid}"`) - uuid_cache_save() -} - -/** Looks up a UUID from an IGN */ -export function uuid_cache_lookup(ign: string): string | null { - return uuid_cache.get(ign) ?? null +export async function saveUuidCache() { + await UUID_CACHE_MUTEX.runExclusive(async () => { + saveConfigFile( + UUID_CACHE_FILE, + JSON.stringify(Object.fromEntries(uuid_cache.entries())), + ) + console.log('[UUID Cache] Saved UUID cache') + }) } diff --git a/server/src/utilities.ts b/server/src/utilities.ts deleted file mode 100644 index 809f5767..00000000 --- a/server/src/utilities.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { promises as fs_promises } from 'fs' - -/** - * Attempts to load a text-file from the given path. If the file does not exist then the given fallback text is saved - * into a new text-file at the given path. - * - * @param {string} path The path of the text-file. - * @param {string} fallback The text to default to should the text-file not exist. - * @return {string} Returns the contents of the text-file, or the fallback text. - * - * @author Protonull - */ -export async function loadOrSaveDefaultStringFile( - path: string, - fallback: string, -): Promise { - try { - return await fs_promises.readFile(path, { encoding: 'utf8' }) - } catch (thrown) {} - try { - await fs_promises.writeFile(path, fallback, { encoding: 'utf8' }) - } catch (thrown) { - console.warn('Could not create default file for [' + path + ']') - } - return fallback -} diff --git a/server/yarn.lock b/server/yarn.lock index 2283b625..5e976958 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -336,3 +336,13 @@ yallist@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +zod-validation-error@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-1.3.1.tgz#7134579d2ba3994495133b879a076786c8c270f5" + integrity sha512-cNEXpla+tREtNdAnNKY4xKY1SGOn2yzyuZMu4O0RQylX9apRpUjNcPkEc3uHIAr5Ct7LenjZt6RzjEH6+JsqVQ== + +zod@^3.21.4: + version "3.21.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db" + integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==