diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..67e91b49 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Prettier +4355eb2a9a3c7694e0014a5ca2e4fb187a9806e6 diff --git a/server/package.json b/server/package.json index 7e546605..516626f6 100644 --- a/server/package.json +++ b/server/package.json @@ -1,36 +1,36 @@ { - "name": "civmap-server", - "version": "SNAPSHOT", - "private": true, - "author": "Gjum", - "license": "GPL-3.0-only", - "scripts": { - "build": "tsc", - "format": "prettier -w .", - "test": "true", - "start": "node -r source-map-support/register dist/main.js", - "start:dev": "tsc && node --inspect -r source-map-support/register dist/main.js" - }, - "dependencies": { - "async-mutex": "^0.4.0", - "better-sqlite3": "^8.5.0", - "kysely": "^0.26.1", - "source-map-support": "^0.5.21", - "zod": "^3.21.4", - "zod-validation-error": "^1.3.1" - }, - "devDependencies": { - "@types/better-sqlite3": "^7.6.4", - "@types/node": "^18.17.4", - "dotenv": "^16.0.1", - "prettier": "^3.0.1", - "typescript": "^5.1.6" - }, - "prettier": { - "useTabs": true, - "tabWidth": 2, - "trailingComma": "all", - "singleQuote": true, - "semi": false - } + "name": "civmap-server", + "version": "SNAPSHOT", + "private": true, + "author": "Gjum", + "license": "GPL-3.0-only", + "scripts": { + "build": "tsc", + "format": "prettier -w .", + "test": "true", + "start": "node -r source-map-support/register dist/main.js", + "start:dev": "tsc && node --inspect -r source-map-support/register dist/main.js" + }, + "dependencies": { + "async-mutex": "^0.4.0", + "better-sqlite3": "^9.5.0", + "kysely": "^0.26.1", + "source-map-support": "^0.5.21", + "zod": "^3.21.4", + "zod-validation-error": "^1.3.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.4", + "@types/node": "^18.17.4", + "dotenv": "^16.0.1", + "prettier": "^3.0.1", + "typescript": "^5.1.6" + }, + "prettier": { + "useTabs": false, + "tabWidth": 4, + "trailingComma": "all", + "singleQuote": false, + "semi": true + } } diff --git a/server/src/Renderer.ts b/server/src/Renderer.ts index 608504e5..3d6f603b 100644 --- a/server/src/Renderer.ts +++ b/server/src/Renderer.ts @@ -1,34 +1,34 @@ -import { spawn } from 'child_process' -import { promisify } from 'util' -import * as database from './database' +import { spawn } from "child_process"; +import { promisify } from "util"; +import * as database from "./database"; export async function renderTile( - dimension: string, - tileX: number, - tileZ: number, + dimension: string, + tileX: number, + tileZ: number, ) { - const allChunks = await database.getRegionChunks(dimension, tileX, tileZ) + const allChunks = await database.getRegionChunks(dimension, tileX, tileZ); - const proc = spawn( - '../render/target/release/civmap-render', - [String(tileX), String(tileZ), 'tiles'], - { cwd: '../render' }, // so render can find blocks.json - ) - proc.stdout.pipe(process.stdout) - proc.stderr.pipe(process.stderr) + const proc = spawn( + "../render/target/release/civmap-render", + [String(tileX), String(tileZ), "tiles"], + { cwd: "../render" }, // so render can find blocks.json + ); + proc.stdout.pipe(process.stdout); + proc.stderr.pipe(process.stderr); - const write = promisify(proc.stdin.write.bind(proc.stdin)) + const write = promisify(proc.stdin.write.bind(proc.stdin)); - const numBuf = Buffer.allocUnsafe(4) - numBuf.writeUInt32BE(allChunks.length) - await write(numBuf) + const numBuf = Buffer.allocUnsafe(4); + numBuf.writeUInt32BE(allChunks.length); + await write(numBuf); - const chunkHeaderBuf = Buffer.allocUnsafe(4 + 4 + 2) // reused. 32+32+16 bit - for (const chunk of allChunks) { - chunkHeaderBuf.writeInt32BE(chunk.chunk_x, 0) - chunkHeaderBuf.writeInt32BE(chunk.chunk_z, 4) - chunkHeaderBuf.writeUInt16BE(chunk.version, 8) - await write(chunkHeaderBuf) - await write(chunk.data) - } + const chunkHeaderBuf = Buffer.allocUnsafe(4 + 4 + 2); // reused. 32+32+16 bit + for (const chunk of allChunks) { + chunkHeaderBuf.writeInt32BE(chunk.chunk_x, 0); + chunkHeaderBuf.writeInt32BE(chunk.chunk_z, 4); + chunkHeaderBuf.writeUInt16BE(chunk.version, 8); + await write(chunkHeaderBuf); + await write(chunk.data); + } } diff --git a/server/src/cli.ts b/server/src/cli.ts index 895690b0..81ccf1f5 100644 --- a/server/src/cli.ts +++ b/server/src/cli.ts @@ -1,137 +1,137 @@ -import lib_readline from 'readline' -import lib_stream from 'stream' +import lib_readline from "readline"; +import lib_stream from "stream"; -import * as metadata from './metadata' +import * as metadata from "./metadata"; //idk where these come from lol interface TerminalExtras { - output: lib_stream.Writable - _refreshLine(): void + output: lib_stream.Writable; + _refreshLine(): void; } -type TermType = lib_readline.Interface & TerminalExtras +type TermType = lib_readline.Interface & TerminalExtras; const term = lib_readline.createInterface({ - input: process.stdin, - output: process.stdout, -}) as TermType + input: process.stdin, + output: process.stdout, +}) as TermType; -if (!('MAPSYNC_DUMB_TERM' in process.env)) { - //Adapted from https://stackoverflow.com/questions/10606814/readline-with-console-log-in-the-background/10608048#10608048 - function fixStdoutFor(term: TermType) { - var oldStdout = process.stdout - var newStdout = Object.create(oldStdout) - var oldStderr = process.stderr - var newStderr = Object.create(oldStdout) - function write_func(outout: lib_stream.Writable) { - return function (this: lib_stream.Writable) { - term.output.write('\x1b[2K\r') - var result = outout.write.apply( - this, - Array.prototype.slice.call(arguments) as any, - ) - term._refreshLine() - return result - } - } - newStdout.write = write_func(oldStdout) - newStderr.write = write_func(oldStderr) - Object.defineProperty(process, 'stdout', { - get: function () { - return newStdout - }, - }) - Object.defineProperty(process, 'stderr', { - get: function () { - return newStderr - }, - }) - } - fixStdoutFor(term) - const old_log = console.log - console.log = function () { - term.output.write('\x1b[2K\r') - old_log.apply(this, arguments as any) - term._refreshLine() - } - const old_error = console.error - console.error = function () { - term.output.write('\x1b[2K\r') - old_error.apply(this, arguments as any) - term._refreshLine() - } +if (!("MAPSYNC_DUMB_TERM" in process.env)) { + //Adapted from https://stackoverflow.com/questions/10606814/readline-with-console-log-in-the-background/10608048#10608048 + function fixStdoutFor(term: TermType) { + var oldStdout = process.stdout; + var newStdout = Object.create(oldStdout); + var oldStderr = process.stderr; + var newStderr = Object.create(oldStdout); + function write_func(outout: lib_stream.Writable) { + return function (this: lib_stream.Writable) { + term.output.write("\x1b[2K\r"); + var result = outout.write.apply( + this, + Array.prototype.slice.call(arguments) as any, + ); + term._refreshLine(); + return result; + }; + } + newStdout.write = write_func(oldStdout); + newStderr.write = write_func(oldStderr); + Object.defineProperty(process, "stdout", { + get: function () { + return newStdout; + }, + }); + Object.defineProperty(process, "stderr", { + get: function () { + return newStderr; + }, + }); + } + fixStdoutFor(term); + const old_log = console.log; + console.log = function () { + term.output.write("\x1b[2K\r"); + old_log.apply(this, arguments as any); + term._refreshLine(); + }; + const old_error = console.error; + console.error = function () { + term.output.write("\x1b[2K\r"); + old_error.apply(this, arguments as any); + term._refreshLine(); + }; } async function handle_input(input: string): Promise { - const command_end_i = input.indexOf(' ') - const command = ( - command_end_i > -1 ? input.substring(0, command_end_i) : input - ).toLowerCase() - const extras = command_end_i > -1 ? input.substring(command_end_i + 1) : '' + const command_end_i = input.indexOf(" "); + const command = ( + command_end_i > -1 ? input.substring(0, command_end_i) : input + ).toLowerCase(); + const extras = command_end_i > -1 ? input.substring(command_end_i + 1) : ""; - if (command === '') { - } else if (command === 'ping') console.log('pong') - else if (command === 'help') { - console.log('ping - Prints "pong" for my sanity. -SirAlador') - console.log( - 'help - Prints info about commands, including the help command.', - ) - console.log('whitelist_load - Loads the whitelist from disk') - console.log('whitelist_save - Saves the whitelist to disk') - console.log( - 'whitelist_add - Adds the given account UUID to the\n whitelist, and saves the whitelist to disk', - ) - console.log( - 'whitelist_add_ign - Adds the UUID cached with the\n given IGN to the whitelist, and saves the whitelist to disk', - ) - console.log( - 'whitelist_remove - Removes the given account UUID\n from the whitelist, and saves the whitelist to disk', - ) - 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.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 - 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.getCachedPlayerUuid(ign) - if (uuid == null) throw new Error('No cached UUID for IGN ' + ign) - 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 - 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.getCachedPlayerUuid(ign) - if (uuid == null) throw new Error('No cached UUID for IGN ' + ign) - metadata.whitelist.delete(uuid) - await metadata.saveWhitelist() - } else { - throw new Error(`Unknown command "${command}"`) - } + if (command === "") { + } else if (command === "ping") console.log("pong"); + else if (command === "help") { + console.log('ping - Prints "pong" for my sanity. -SirAlador'); + console.log( + "help - Prints info about commands, including the help command.", + ); + console.log("whitelist_load - Loads the whitelist from disk"); + console.log("whitelist_save - Saves the whitelist to disk"); + console.log( + "whitelist_add - Adds the given account UUID to the\n whitelist, and saves the whitelist to disk", + ); + console.log( + "whitelist_add_ign - Adds the UUID cached with the\n given IGN to the whitelist, and saves the whitelist to disk", + ); + console.log( + "whitelist_remove - Removes the given account UUID\n from the whitelist, and saves the whitelist to disk", + ); + 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.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; + 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.getCachedPlayerUuid(ign); + if (uuid == null) throw new Error("No cached UUID for IGN " + ign); + 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; + 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.getCachedPlayerUuid(ign); + if (uuid == null) throw new Error("No cached UUID for IGN " + ign); + metadata.whitelist.delete(uuid); + await metadata.saveWhitelist(); + } else { + throw new Error(`Unknown command "${command}"`); + } } function input_loop() { - console.log('===========================================================') - term.question('>', (input: string) => - handle_input(input.trim()) - .catch((e) => { - console.error('Command failed:') - console.error(e) - }) - .finally(input_loop), - ) + console.log("==========================================================="); + term.question(">", (input: string) => + handle_input(input.trim()) + .catch((e) => { + console.error("Command failed:"); + console.error(e); + }) + .finally(input_loop), + ); } -input_loop() +input_loop(); diff --git a/server/src/constants.ts b/server/src/constants.ts index da38c3bb..94161821 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -1,8 +1,8 @@ export const SUPPORTED_VERSIONS = new Set([ - '2.0.1-1.18.2+fabric', - '2.0.1-1.18.2+forge', -]) + "2.0.1-1.18.2+fabric", + "2.0.1-1.18.2+forge", +]); // SHA1 produces 160-bit (20-byte) hashes // https://en.wikipedia.org/wiki/SHA-1 -export const SHA1_HASH_LENGTH = 20 +export const SHA1_HASH_LENGTH = 20; diff --git a/server/src/database.ts b/server/src/database.ts index afae704e..0b073c05 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,69 +1,73 @@ -import * as kysely from 'kysely' -import sqlite from 'better-sqlite3' -import { DATA_FOLDER } from './metadata' -import { type Pos2D } from './model' +import * as kysely from "kysely"; +import sqlite from "better-sqlite3"; +import { DATA_FOLDER } from "./metadata"; +import { type Pos2D } from "./model"; -let database: kysely.Kysely | null = null +let database: kysely.Kysely | null = null; export interface Database { - chunk_data: { - hash: Buffer - version: number - data: Buffer - } - player_chunk: { - world: string - chunk_x: number - chunk_z: number - uuid: string - ts: number - hash: Buffer - } + chunk_data: { + hash: Buffer; + version: number; + data: Buffer; + }; + player_chunk: { + world: string; + chunk_x: number; + chunk_z: number; + uuid: string; + ts: number; + hash: Buffer; + }; } export function get() { - if (!database) { - database = new kysely.Kysely({ - dialect: new kysely.SqliteDialect({ - database: async () => - sqlite(process.env['SQLITE_PATH'] ?? `${DATA_FOLDER}/db.sqlite`, {}), - }), - }) - } - return database + if (!database) { + database = new kysely.Kysely({ + dialect: new kysely.SqliteDialect({ + database: async () => + sqlite( + process.env["SQLITE_PATH"] ?? + `${DATA_FOLDER}/db.sqlite`, + {}, + ), + }), + }); + } + return database; } export async function setup() { - await get() - .schema.createTable('chunk_data') - .ifNotExists() - .addColumn('hash', 'blob', (col) => col.notNull().primaryKey()) - .addColumn('version', 'integer', (col) => col.notNull()) - .addColumn('data', 'blob', (col) => col.notNull()) - .execute() - await get() - .schema.createTable('player_chunk') - .ifNotExists() - .addColumn('world', 'text', (col) => col.notNull()) - .addColumn('chunk_x', 'integer', (col) => col.notNull()) - .addColumn('chunk_z', 'integer', (col) => col.notNull()) - .addColumn('uuid', 'text', (col) => col.notNull()) - .addColumn('ts', 'bigint', (col) => col.notNull()) - .addColumn('hash', 'blob', (col) => col.notNull()) - .addPrimaryKeyConstraint('PK_coords_and_player', [ - 'world', - 'chunk_x', - 'chunk_z', - 'uuid', - ]) - .addForeignKeyConstraint( - 'FK_chunk_ref', - ['hash'], - 'chunk_data', - ['hash'], - (fk) => fk.onUpdate('no action').onDelete('no action'), - ) - .execute() + await get() + .schema.createTable("chunk_data") + .ifNotExists() + .addColumn("hash", "blob", (col) => col.notNull().primaryKey()) + .addColumn("version", "integer", (col) => col.notNull()) + .addColumn("data", "blob", (col) => col.notNull()) + .execute(); + await get() + .schema.createTable("player_chunk") + .ifNotExists() + .addColumn("world", "text", (col) => col.notNull()) + .addColumn("chunk_x", "integer", (col) => col.notNull()) + .addColumn("chunk_z", "integer", (col) => col.notNull()) + .addColumn("uuid", "text", (col) => col.notNull()) + .addColumn("ts", "bigint", (col) => col.notNull()) + .addColumn("hash", "blob", (col) => col.notNull()) + .addPrimaryKeyConstraint("PK_coords_and_player", [ + "world", + "chunk_x", + "chunk_z", + "uuid", + ]) + .addForeignKeyConstraint( + "FK_chunk_ref", + ["hash"], + "chunk_data", + ["hash"], + (fk) => fk.onUpdate("no action").onDelete("no action"), + ) + .execute(); } /** @@ -71,53 +75,57 @@ export async function setup() { * having the highest (aka newest) timestamp. */ export function getRegionTimestamps(dimension: string) { - // computing region coordinates in SQL requires truncating, not rounding - return get() - .selectFrom('player_chunk') - .select([ - (eb) => - kysely.sql`floor(${eb.ref('chunk_x')} / 32.0)`.as('regionX'), - (eb) => - kysely.sql`floor(${eb.ref('chunk_z')} / 32.0)`.as('regionZ'), - (eb) => eb.fn.max('ts').as('timestamp'), - ]) - .where('world', '=', dimension) - .groupBy(['regionX', 'regionZ']) - .orderBy('regionX', 'desc') - .execute() + // computing region coordinates in SQL requires truncating, not rounding + return get() + .selectFrom("player_chunk") + .select([ + (eb) => + kysely.sql`floor(${eb.ref("chunk_x")} / 32.0)`.as( + "regionX", + ), + (eb) => + kysely.sql`floor(${eb.ref("chunk_z")} / 32.0)`.as( + "regionZ", + ), + (eb) => eb.fn.max("ts").as("timestamp"), + ]) + .where("world", "=", dimension) + .groupBy(["regionX", "regionZ"]) + .orderBy("regionX", "desc") + .execute(); } /** * Converts an array of region coords into an array of timestamped chunk coords. */ export async function getChunkTimestamps(dimension: string, regions: Pos2D[]) { - return get() - .with('regions', (db) => - db - .selectFrom('player_chunk') - .select([ - (eb) => - kysely.sql`(cast(floor(${eb.ref( - 'chunk_x', - )} / 32.0) as int) || '_' || cast(floor(${eb.ref( - 'chunk_z', - )} / 32.0) as int))`.as('region'), - 'chunk_x as x', - 'chunk_z as z', - (eb) => eb.fn.max('ts').as('timestamp'), - ]) - .where('world', '=', dimension) - .groupBy(['x', 'z']), - ) - .selectFrom('regions') - .select(['x as chunkX', 'z as chunkZ', 'timestamp']) - .where( - 'region', - 'in', - regions.map((region) => region.x + '_' + region.z), - ) - .orderBy('timestamp', 'desc') - .execute() + return get() + .with("regions", (db) => + db + .selectFrom("player_chunk") + .select([ + (eb) => + kysely.sql`(cast(floor(${eb.ref( + "chunk_x", + )} / 32.0) as int) || '_' || cast(floor(${eb.ref( + "chunk_z", + )} / 32.0) as int))`.as("region"), + "chunk_x as x", + "chunk_z as z", + (eb) => eb.fn.max("ts").as("timestamp"), + ]) + .where("world", "=", dimension) + .groupBy(["x", "z"]), + ) + .selectFrom("regions") + .select(["x as chunkX", "z as chunkZ", "timestamp"]) + .where( + "region", + "in", + regions.map((region) => region.x + "_" + region.z), + ) + .orderBy("timestamp", "desc") + .execute(); } /** @@ -127,85 +135,85 @@ export async function getChunkTimestamps(dimension: string, regions: Pos2D[]) { * database table... may help performance. */ export async function getChunkData( - dimension: string, - chunkX: number, - chunkZ: number, + dimension: string, + chunkX: number, + chunkZ: number, ) { - return get() - .selectFrom('player_chunk') - .innerJoin('chunk_data', 'chunk_data.hash', 'player_chunk.hash') - .select([ - 'chunk_data.hash as hash', - 'chunk_data.version as version', - 'chunk_data.data as data', - 'player_chunk.ts as ts', - ]) - .where('player_chunk.world', '=', dimension) - .where('player_chunk.chunk_x', '=', chunkX) - .where('player_chunk.chunk_z', '=', chunkZ) - .orderBy('player_chunk.ts', 'desc') - .limit(1) - .executeTakeFirst() + return get() + .selectFrom("player_chunk") + .innerJoin("chunk_data", "chunk_data.hash", "player_chunk.hash") + .select([ + "chunk_data.hash as hash", + "chunk_data.version as version", + "chunk_data.data as data", + "player_chunk.ts as ts", + ]) + .where("player_chunk.world", "=", dimension) + .where("player_chunk.chunk_x", "=", chunkX) + .where("player_chunk.chunk_z", "=", chunkZ) + .orderBy("player_chunk.ts", "desc") + .limit(1) + .executeTakeFirst(); } /** * Stores a player's chunk data. */ export async function storeChunkData( - dimension: string, - chunkX: number, - chunkZ: number, - uuid: string, - timestamp: number, - version: number, - hash: Buffer, - data: Buffer, + dimension: string, + chunkX: number, + chunkZ: number, + uuid: string, + timestamp: number, + version: number, + hash: Buffer, + data: Buffer, ) { - await get() - .insertInto('chunk_data') - .values({ hash, version, data }) - .onConflict((oc) => oc.column('hash').doNothing()) - .execute() - await get() - .replaceInto('player_chunk') - .values({ - world: dimension, - chunk_x: chunkX, - chunk_z: chunkZ, - uuid, - ts: timestamp, - hash, - }) - .execute() + await get() + .insertInto("chunk_data") + .values({ hash, version, data }) + .onConflict((oc) => oc.column("hash").doNothing()) + .execute(); + await get() + .replaceInto("player_chunk") + .values({ + world: dimension, + chunk_x: chunkX, + chunk_z: chunkZ, + uuid, + ts: timestamp, + hash, + }) + .execute(); } /** * Gets all the [latest] chunks within a region. */ export async function getRegionChunks( - dimension: string, - regionX: number, - regionZ: number, + dimension: string, + regionX: number, + regionZ: number, ) { - const minChunkX = regionX << 4, - maxChunkX = minChunkX + 16 - const minChunkZ = regionZ << 4, - maxChunkZ = minChunkZ + 16 - return get() - .selectFrom('player_chunk') - .innerJoin('chunk_data', 'chunk_data.hash', 'player_chunk.hash') - .select([ - 'player_chunk.chunk_x as chunk_x', - 'player_chunk.chunk_z as chunk_z', - (eb) => eb.fn.max('player_chunk.ts').as('timestamp'), - 'chunk_data.version as version', - 'chunk_data.data as data', - ]) - .where('player_chunk.world', '=', dimension) - .where('player_chunk.chunk_x', '>=', minChunkX) - .where('player_chunk.chunk_x', '<', maxChunkX) - .where('player_chunk.chunk_z', '>=', minChunkZ) - .where('player_chunk.chunk_z', '<', maxChunkZ) - .orderBy('player_chunk.ts', 'desc') - .execute() + const minChunkX = regionX << 4, + maxChunkX = minChunkX + 16; + const minChunkZ = regionZ << 4, + maxChunkZ = minChunkZ + 16; + return get() + .selectFrom("player_chunk") + .innerJoin("chunk_data", "chunk_data.hash", "player_chunk.hash") + .select([ + "player_chunk.chunk_x as chunk_x", + "player_chunk.chunk_z as chunk_z", + (eb) => eb.fn.max("player_chunk.ts").as("timestamp"), + "chunk_data.version as version", + "chunk_data.data as data", + ]) + .where("player_chunk.world", "=", dimension) + .where("player_chunk.chunk_x", ">=", minChunkX) + .where("player_chunk.chunk_x", "<", maxChunkX) + .where("player_chunk.chunk_z", ">=", minChunkZ) + .where("player_chunk.chunk_z", "<", maxChunkZ) + .orderBy("player_chunk.ts", "desc") + .execute(); } diff --git a/server/src/deps/errors.ts b/server/src/deps/errors.ts index 06acd30d..129a0314 100644 --- a/server/src/deps/errors.ts +++ b/server/src/deps/errors.ts @@ -1,10 +1,10 @@ -import node_os from 'node:os' -import node_utils from 'node:util' +import node_os from "node:os"; +import node_utils from "node:util"; export enum ErrorType { - FileExists, - FileNotFound, - UNKNOWN, + FileExists, + FileNotFound, + UNKNOWN, } /** @@ -12,31 +12,31 @@ export enum ErrorType { * 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 - } + 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)) + 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)); } /** @@ -45,5 +45,5 @@ export function ensureError(error: any): Error { * https://www.proposals.es/proposals/throw%20expressions */ export function inlineThrow(error: any): T { - throw error + throw error; } diff --git a/server/src/deps/json.ts b/server/src/deps/json.ts index fb3e6faf..21eda8f6 100644 --- a/server/src/deps/json.ts +++ b/server/src/deps/json.ts @@ -1,16 +1,16 @@ -export type JSONObject = { [key: string]: JSONValue | undefined } -export type JSONArray = JSONValue[] +export type JSONObject = { [key: string]: JSONValue | undefined }; +export type JSONArray = JSONValue[]; export type JSONValue = - | JSONObject - | JSONArray - | string - | number - | boolean - | null + | 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) + return JSON.parse(raw); } diff --git a/server/src/main.ts b/server/src/main.ts index 84132a81..c082e3c2 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,147 +1,159 @@ -import './cli' -import * as database from './database' -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' - -let config: metadata.Config = null! +import "./cli"; +import * as database from "./database"; +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"; + +let config: metadata.Config = null!; Promise.resolve().then(async () => { - await database.setup() + await database.setup(); - config = metadata.getConfig() + 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() + // 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() -}) + new Main(); +}); -type ProtocolClient = TcpClient // TODO cleanup +type ProtocolClient = TcpClient; // TODO cleanup export class Main { - server = new TcpServer(this) - - //Cannot be async, as it's caled from a synchronous constructor - handleClientConnected(client: ProtocolClient) {} - - async handleClientAuthenticated(client: ProtocolClient) { - if (!client.uuid) throw new Error('Client not authenticated') - - metadata.cachePlayerUuid(client.mcName!, client.uuid!) - await metadata.saveUuidCache() - - if (config.whitelist) { - if (!metadata.whitelist.has(client.uuid)) { - client.log( - `Rejected unwhitelisted user ${client.mcName} (${client.uuid})`, - ) - client.kick(`Not whitelisted`) - return - } - } - - // TODO check version, mc server, user access - - const timestamps = await database.getRegionTimestamps(client.world!) - client.send({ - type: 'RegionTimestamps', - world: client.world!, - regions: timestamps, - }) - } - - handleClientDisconnected(client: ProtocolClient) {} - - handleClientPacketReceived(client: ProtocolClient, pkt: ClientPacket) { - client.debug(client.mcName + ' <- ' + pkt.type) - switch (pkt.type) { - case 'ChunkTile': - return this.handleChunkTilePacket(client, pkt) - case 'CatchupRequest': - return this.handleCatchupRequest(client, pkt) - case 'RegionCatchup': - return this.handleRegionCatchupPacket(client, pkt) - default: - throw new Error( - `Unknown packet '${(pkt as any).type}' from client ${client.id}`, - ) - } - } - - async handleChunkTilePacket(client: ProtocolClient, pkt: ChunkTilePacket) { - if (!client.uuid) throw new Error(`${client.name} is not authenticated`) - - // TODO ignore if same chunk hash exists in db - - await database - .storeChunkData( - pkt.world, - pkt.chunk_x, - pkt.chunk_z, - client.uuid, - pkt.ts, - pkt.data.version, - pkt.data.hash, - pkt.data.data, - ) - .catch(console.error) - - // TODO small timeout, then skip if other client already has it - for (const otherClient of Object.values(this.server.clients)) { - if (client === otherClient) continue - otherClient.send(pkt) - } - - // TODO queue tile render for web map - } - - async handleCatchupRequest( - client: ProtocolClient, - pkt: CatchupRequestPacket, - ) { - if (!client.uuid) throw new Error(`${client.name} is not authenticated`) - - for (const req of pkt.chunks) { - let chunk = await database.getChunkData(pkt.world, req.chunkX, req.chunkZ) - if (!chunk) { - console.error(`${client.name} requested unavailable chunk`, { - world: pkt.world, - ...req, - }) - continue - } - - if (chunk.ts > req.timestamp) continue // someone sent a new chunk, which presumably got relayed to the client - if (chunk.ts < req.timestamp) continue // the client already has a chunk newer than this - - client.send({ - type: 'ChunkTile', - world: pkt.world, - chunk_x: req.chunkX, - chunk_z: req.chunkX, - ts: req.timestamp, - data: { - hash: chunk.hash, - data: chunk.data, - version: chunk.version, - }, - }) - } - } - - async handleRegionCatchupPacket( - client: ProtocolClient, - pkt: RegionCatchupPacket, - ) { - if (!client.uuid) throw new Error(`${client.name} is not authenticated`) - - const chunks = await database.getChunkTimestamps(pkt.world, pkt.regions) - if (chunks.length) - client.send({ type: 'Catchup', world: pkt.world, chunks }) - } + server = new TcpServer(this); + + //Cannot be async, as it's caled from a synchronous constructor + handleClientConnected(client: ProtocolClient) {} + + async handleClientAuthenticated(client: ProtocolClient) { + if (!client.uuid) throw new Error("Client not authenticated"); + + metadata.cachePlayerUuid(client.mcName!, client.uuid!); + await metadata.saveUuidCache(); + + if (config.whitelist) { + if (!metadata.whitelist.has(client.uuid)) { + client.log( + `Rejected unwhitelisted user ${client.mcName} (${client.uuid})`, + ); + client.kick(`Not whitelisted`); + return; + } + } + + // TODO check version, mc server, user access + + const timestamps = await database.getRegionTimestamps(client.world!); + client.send({ + type: "RegionTimestamps", + world: client.world!, + regions: timestamps, + }); + } + + handleClientDisconnected(client: ProtocolClient) {} + + handleClientPacketReceived(client: ProtocolClient, pkt: ClientPacket) { + client.debug(client.mcName + " <- " + pkt.type); + switch (pkt.type) { + case "ChunkTile": + return this.handleChunkTilePacket(client, pkt); + case "CatchupRequest": + return this.handleCatchupRequest(client, pkt); + case "RegionCatchup": + return this.handleRegionCatchupPacket(client, pkt); + default: + throw new Error( + `Unknown packet '${(pkt as any).type}' from client ${ + client.id + }`, + ); + } + } + + async handleChunkTilePacket(client: ProtocolClient, pkt: ChunkTilePacket) { + if (!client.uuid) + throw new Error(`${client.name} is not authenticated`); + + // TODO ignore if same chunk hash exists in db + + await database + .storeChunkData( + pkt.world, + pkt.chunk_x, + pkt.chunk_z, + client.uuid, + pkt.ts, + pkt.data.version, + pkt.data.hash, + pkt.data.data, + ) + .catch(console.error); + + // TODO small timeout, then skip if other client already has it + for (const otherClient of Object.values(this.server.clients)) { + if (client === otherClient) continue; + otherClient.send(pkt); + } + + // TODO queue tile render for web map + } + + async handleCatchupRequest( + client: ProtocolClient, + pkt: CatchupRequestPacket, + ) { + if (!client.uuid) + throw new Error(`${client.name} is not authenticated`); + + for (const req of pkt.chunks) { + let chunk = await database.getChunkData( + pkt.world, + req.chunkX, + req.chunkZ, + ); + if (!chunk) { + console.error(`${client.name} requested unavailable chunk`, { + world: pkt.world, + ...req, + }); + continue; + } + + if (chunk.ts > req.timestamp) continue; // someone sent a new chunk, which presumably got relayed to the client + if (chunk.ts < req.timestamp) continue; // the client already has a chunk newer than this + + client.send({ + type: "ChunkTile", + world: pkt.world, + chunk_x: req.chunkX, + chunk_z: req.chunkX, + ts: req.timestamp, + data: { + hash: chunk.hash, + data: chunk.data, + version: chunk.version, + }, + }); + } + } + + async handleRegionCatchupPacket( + client: ProtocolClient, + pkt: RegionCatchupPacket, + ) { + if (!client.uuid) + throw new Error(`${client.name} is not authenticated`); + + const chunks = await database.getChunkTimestamps( + pkt.world, + pkt.regions, + ); + if (chunks.length) + client.send({ type: "Catchup", world: pkt.world, chunks }); + } } diff --git a/server/src/metadata.ts b/server/src/metadata.ts index 4ce11230..f2519833 100644 --- a/server/src/metadata.ts +++ b/server/src/metadata.ts @@ -1,18 +1,18 @@ -import node_fs from 'node:fs' -import node_path from 'node:path' -import { Mutex } from 'async-mutex' -import * as errors from './deps/errors' -import * as json from './deps/json' -import * as z from 'zod' -import { fromZodError } from 'zod-validation-error' +import node_fs from "node:fs"; +import node_path from "node:path"; +import { Mutex } from "async-mutex"; +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' +export const DATA_FOLDER = process.env["MAPSYNC_DATA_DIR"] ?? "./mapsync"; try { - node_fs.mkdirSync(DATA_FOLDER, { recursive: true }) - console.log(`Created data folder "${DATA_FOLDER}"`) + node_fs.mkdirSync(DATA_FOLDER, { recursive: true }); + console.log(`Created data folder "${DATA_FOLDER}"`); } catch (e: any) { - if (errors.getErrorType(e) !== errors.ErrorType.FileExists) throw e - console.log(`Using data folder "${DATA_FOLDER}"`) + if (errors.getErrorType(e) !== errors.ErrorType.FileExists) throw e; + console.log(`Using data folder "${DATA_FOLDER}"`); } /** @@ -24,31 +24,35 @@ try { * @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, + 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 - } + 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; + } } /** @@ -58,102 +62,102 @@ function parseConfigFile( * @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') + file = node_path.resolve(DATA_FOLDER, file); + if (typeof content !== "string") { + content = JSON.stringify(content, null, 2); + } + node_fs.writeFileSync(file, content, "utf8"); } // ------------------------------------------------------------ // // Config // ------------------------------------------------------------ // -const CONFIG_FILE = 'config.json' +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 + 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, - })) + return parseConfigFile(CONFIG_FILE, CONFIG_SCHEMA.parse, () => ({ + gameAddress: "localhost:25565", + whitelist: true, + })); } // ------------------------------------------------------------ // // Whitelist // ------------------------------------------------------------ // -const WHITELIST_FILE = 'whitelist.json' -const WHITELIST_MUTEX = new Mutex() -const WHITELIST_SCHEMA = z.array(z.string().uuid()) -export const whitelist = new Set() +const WHITELIST_FILE = "whitelist.json"; +const WHITELIST_MUTEX = new Mutex(); +const WHITELIST_SCHEMA = z.array(z.string().uuid()); +export const whitelist = new Set(); export async function loadWhitelist() { - await WHITELIST_MUTEX.runExclusive(async () => { - const parsed = parseConfigFile( - WHITELIST_FILE, - WHITELIST_SCHEMA.parse, - () => [], - ) - whitelist.clear() - for (const entry of parsed) { - whitelist.add(entry) - } - console.log('[Whitelist] Loaded whitelist') - }) + await WHITELIST_MUTEX.runExclusive(async () => { + const parsed = parseConfigFile( + WHITELIST_FILE, + WHITELIST_SCHEMA.parse, + () => [], + ); + whitelist.clear(); + for (const entry of parsed) { + whitelist.add(entry); + } + console.log("[Whitelist] Loaded whitelist"); + }); } export async function saveWhitelist() { - await WHITELIST_MUTEX.runExclusive(async () => { - saveConfigFile(WHITELIST_FILE, JSON.stringify(Array.from(whitelist))) - console.log('[Whitelist] Saved whitelist') - }) + await WHITELIST_MUTEX.runExclusive(async () => { + saveConfigFile(WHITELIST_FILE, JSON.stringify(Array.from(whitelist))); + console.log("[Whitelist] Saved whitelist"); + }); } // ------------------------------------------------------------ // // UUID Cache // ------------------------------------------------------------ // -const UUID_CACHE_FILE = 'uuid_cache.json' -const UUID_CACHE_MUTEX = new Mutex() -const UUID_CACHE_SCHEMA = z.record(z.string().uuid()) +const UUID_CACHE_FILE = "uuid_cache.json"; +const UUID_CACHE_MUTEX = new Mutex(); +const UUID_CACHE_SCHEMA = z.record(z.string().uuid()); // IGN UUID -const uuid_cache = new Map() +const uuid_cache = new Map(); export function getCachedPlayerUuid(playerName: string) { - return uuid_cache.get(playerName) ?? null + return uuid_cache.get(playerName) ?? null; } export function cachePlayerUuid(playerName: string, playerUUID: string) { - uuid_cache.set(playerName, playerUUID) + 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, - () => ({}), - ) - uuid_cache.clear() - for (const [key, value] of Object.entries(parsed)) { - uuid_cache.set(key, String(value)) - } - console.log('[UUID Cache] Loaded UUID cache') - }) + await UUID_CACHE_MUTEX.runExclusive(async () => { + const parsed = parseConfigFile( + UUID_CACHE_FILE, + UUID_CACHE_SCHEMA.parse, + () => ({}), + ); + uuid_cache.clear(); + for (const [key, value] of Object.entries(parsed)) { + uuid_cache.set(key, String(value)); + } + console.log("[UUID Cache] Loaded UUID cache"); + }); } 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') - }) + 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/model.ts b/server/src/model.ts index f45897b7..dc990c2f 100644 --- a/server/src/model.ts +++ b/server/src/model.ts @@ -1,16 +1,16 @@ export interface CatchupRegion { - readonly regionX: number - readonly regionZ: number - readonly timestamp: number + readonly regionX: number; + readonly regionZ: number; + readonly timestamp: number; } export interface CatchupChunk { - readonly chunkX: number - readonly chunkZ: number - readonly timestamp: number + readonly chunkX: number; + readonly chunkZ: number; + readonly timestamp: number; } export interface Pos2D { - readonly x: number - readonly z: number + readonly x: number; + readonly z: number; } diff --git a/server/src/protocol/BufReader.ts b/server/src/protocol/BufReader.ts index 6b68268b..e4d39ef2 100644 --- a/server/src/protocol/BufReader.ts +++ b/server/src/protocol/BufReader.ts @@ -1,100 +1,100 @@ /** Each read advances the internal offset into the buffer. */ export class BufReader { - private off = 0 - private offStack: number[] = [] + private off = 0; + private offStack: number[] = []; - constructor(private buf: Buffer) {} + constructor(private buf: Buffer) {} - saveOffset() { - this.offStack.push(this.off) - } + saveOffset() { + this.offStack.push(this.off); + } - restoreOffset() { - const off = this.offStack.pop() - if (off === undefined) throw new Error('Offset stack is empty') - this.off = off - } + restoreOffset() { + const off = this.offStack.pop(); + if (off === undefined) throw new Error("Offset stack is empty"); + this.off = off; + } - readUInt8() { - const val = this.buf.readUInt8(this.off) - this.off += 1 - return val - } + readUInt8() { + const val = this.buf.readUInt8(this.off); + this.off += 1; + return val; + } - readInt8() { - const val = this.buf.readInt8(this.off) - this.off += 1 - return val - } + readInt8() { + const val = this.buf.readInt8(this.off); + this.off += 1; + return val; + } - readUInt16() { - const val = this.buf.readUInt16BE(this.off) - this.off += 2 - return val - } + readUInt16() { + const val = this.buf.readUInt16BE(this.off); + this.off += 2; + return val; + } - readInt16() { - const val = this.buf.readInt16BE(this.off) - this.off += 2 - return val - } + readInt16() { + const val = this.buf.readInt16BE(this.off); + this.off += 2; + return val; + } - readUInt32() { - const val = this.buf.readUInt32BE(this.off) - this.off += 4 - return val - } + readUInt32() { + const val = this.buf.readUInt32BE(this.off); + this.off += 4; + return val; + } - readInt32() { - const val = this.buf.readInt32BE(this.off) - this.off += 4 - return val - } + readInt32() { + const val = this.buf.readInt32BE(this.off); + this.off += 4; + return val; + } - readUInt64() { - const valBig = this.buf.readBigUInt64BE(this.off) - if (valBig > Number.MAX_SAFE_INTEGER) { - throw new Error(`64-bit number too big: ${valBig}`) - } - this.off += 8 - return Number(valBig) - } + readUInt64() { + const valBig = this.buf.readBigUInt64BE(this.off); + if (valBig > Number.MAX_SAFE_INTEGER) { + throw new Error(`64-bit number too big: ${valBig}`); + } + this.off += 8; + return Number(valBig); + } - readInt64() { - const valBig = this.buf.readBigInt64BE(this.off) - if (valBig > Number.MAX_SAFE_INTEGER) { - throw new Error(`64-bit number too big: ${valBig}`) - } - if (valBig < Number.MIN_SAFE_INTEGER) { - throw new Error(`64-bit number too small: ${valBig}`) - } - this.off += 8 - return Number(valBig) - } + readInt64() { + const valBig = this.buf.readBigInt64BE(this.off); + if (valBig > Number.MAX_SAFE_INTEGER) { + throw new Error(`64-bit number too big: ${valBig}`); + } + if (valBig < Number.MIN_SAFE_INTEGER) { + throw new Error(`64-bit number too small: ${valBig}`); + } + this.off += 8; + return Number(valBig); + } - /** length-prefixed (32 bits), UTF-8 encoded */ - readString() { - const len = this.readUInt32() - const str = this.buf.toString('utf8', this.off, this.off + len) - this.off += len - return str - } + /** length-prefixed (32 bits), UTF-8 encoded */ + readString() { + const len = this.readUInt32(); + const str = this.buf.toString("utf8", this.off, this.off + len); + this.off += len; + return str; + } - readBufWithLen() { - const len = this.readUInt32() - return this.readBufLen(len) - } + readBufWithLen() { + const len = this.readUInt32(); + return this.readBufLen(len); + } - readBufLen(length: number) { - // simply returning a slice() would retain the entire buf in memory - const buf = Buffer.allocUnsafe(length) - this.buf.copy(buf, 0, this.off, this.off + length) - this.off += length - return buf - } + readBufLen(length: number) { + // simply returning a slice() would retain the entire buf in memory + const buf = Buffer.allocUnsafe(length); + this.buf.copy(buf, 0, this.off, this.off + length); + this.off += length; + return buf; + } - /** any reads after this will fail */ - readRemainder() { - return this.readBufLen(this.buf.length - this.off) - } + /** any reads after this will fail */ + readRemainder() { + return this.readBufLen(this.buf.length - this.off); + } } diff --git a/server/src/protocol/BufWriter.ts b/server/src/protocol/BufWriter.ts index 9915f512..0dba9ea9 100644 --- a/server/src/protocol/BufWriter.ts +++ b/server/src/protocol/BufWriter.ts @@ -1,100 +1,100 @@ /** Each write advances the internal offset into the buffer. * Grows the buffer to twice the current size if a write would exceed the buffer. */ export class BufWriter { - private off = 0 - private buf: Buffer + private off = 0; + private buf: Buffer; - constructor(initialSize?: number) { - this.buf = Buffer.alloc(initialSize || 1024) - } + constructor(initialSize?: number) { + this.buf = Buffer.alloc(initialSize || 1024); + } - /** Returns a slice reference to the written bytes so far. */ - getBuffer() { - return this.buf.slice(0, this.off) - } + /** Returns a slice reference to the written bytes so far. */ + getBuffer() { + return this.buf.slice(0, this.off); + } - writeUInt8(val: number) { - this.ensureSpace(1) - this.buf.writeUInt8(val, this.off) - this.off += 1 - } + writeUInt8(val: number) { + this.ensureSpace(1); + this.buf.writeUInt8(val, this.off); + this.off += 1; + } - writeInt8(val: number) { - this.ensureSpace(1) - this.buf.writeInt8(val, this.off) - this.off += 1 - } + writeInt8(val: number) { + this.ensureSpace(1); + this.buf.writeInt8(val, this.off); + this.off += 1; + } - writeUInt16(val: number) { - this.ensureSpace(2) - this.buf.writeUInt16BE(val, this.off) - this.off += 2 - } + writeUInt16(val: number) { + this.ensureSpace(2); + this.buf.writeUInt16BE(val, this.off); + this.off += 2; + } - writeInt16(val: number) { - this.ensureSpace(2) - this.buf.writeInt16BE(val, this.off) - this.off += 2 - } + writeInt16(val: number) { + this.ensureSpace(2); + this.buf.writeInt16BE(val, this.off); + this.off += 2; + } - writeUInt32(val: number) { - this.ensureSpace(4) - this.buf.writeUInt32BE(val, this.off) - this.off += 4 - } + writeUInt32(val: number) { + this.ensureSpace(4); + this.buf.writeUInt32BE(val, this.off); + this.off += 4; + } - writeInt32(val: number) { - this.ensureSpace(4) - this.buf.writeInt32BE(val, this.off) - this.off += 4 - } + writeInt32(val: number) { + this.ensureSpace(4); + this.buf.writeInt32BE(val, this.off); + this.off += 4; + } - writeUInt64(val: number) { - this.ensureSpace(8) - this.buf.writeBigUInt64BE(BigInt(val), this.off) - this.off += 8 - } + writeUInt64(val: number) { + this.ensureSpace(8); + this.buf.writeBigUInt64BE(BigInt(val), this.off); + this.off += 8; + } - writeInt64(val: number) { - this.ensureSpace(8) - this.buf.writeBigInt64BE(BigInt(val), this.off) - this.off += 8 - } + writeInt64(val: number) { + this.ensureSpace(8); + this.buf.writeBigInt64BE(BigInt(val), this.off); + this.off += 8; + } - /** length-prefixed (32 bits), UTF-8 encoded */ - writeString(str: string) { - const strBuf = Buffer.from(str, 'utf8') - this.ensureSpace(4 + strBuf.length) - this.buf.writeUInt32BE(strBuf.length, this.off) - this.off += 4 - this.buf.set(strBuf, this.off) - this.off += strBuf.length - } + /** length-prefixed (32 bits), UTF-8 encoded */ + writeString(str: string) { + const strBuf = Buffer.from(str, "utf8"); + this.ensureSpace(4 + strBuf.length); + this.buf.writeUInt32BE(strBuf.length, this.off); + this.off += 4; + this.buf.set(strBuf, this.off); + this.off += strBuf.length; + } - /** length-prefixed (32 bits), UTF-8 encoded */ - writeBufWithLen(buf: Buffer) { - this.ensureSpace(4 + buf.length) - this.buf.writeUInt32BE(buf.length, this.off) - this.off += 4 - this.buf.set(buf, this.off) - this.off += buf.length - } + /** length-prefixed (32 bits), UTF-8 encoded */ + writeBufWithLen(buf: Buffer) { + this.ensureSpace(4 + buf.length); + this.buf.writeUInt32BE(buf.length, this.off); + this.off += 4; + this.buf.set(buf, this.off); + this.off += buf.length; + } - writeBufRaw(buf: Buffer) { - this.ensureSpace(buf.length) - this.buf.set(buf, this.off) - this.off += buf.length - } + writeBufRaw(buf: Buffer) { + this.ensureSpace(buf.length); + this.buf.set(buf, this.off); + this.off += buf.length; + } - private ensureSpace(bytes: number) { - let len = this.buf.length - while (len <= this.off + bytes) { - len = len * 2 - } - if (len !== this.buf.length) { - const newBuf = Buffer.alloc(len) - this.buf.copy(newBuf, 0, 0, this.off) - this.buf = newBuf - } - } + private ensureSpace(bytes: number) { + let len = this.buf.length; + while (len <= this.off + bytes) { + len = len * 2; + } + if (len !== this.buf.length) { + const newBuf = Buffer.alloc(len); + this.buf.copy(newBuf, 0, 0, this.off); + this.buf = newBuf; + } + } } diff --git a/server/src/protocol/CatchupPacket.ts b/server/src/protocol/CatchupPacket.ts index ea5890f4..d05f839b 100644 --- a/server/src/protocol/CatchupPacket.ts +++ b/server/src/protocol/CatchupPacket.ts @@ -1,22 +1,22 @@ -import { type CatchupChunk } from '../model' -import { BufWriter } from './BufWriter' +import { type CatchupChunk } from "../model"; +import { BufWriter } from "./BufWriter"; export interface CatchupPacket { - type: 'Catchup' - world: string - chunks: CatchupChunk[] + type: "Catchup"; + world: string; + chunks: CatchupChunk[]; } export namespace CatchupPacket { - export function encode(pkt: CatchupPacket, writer: BufWriter) { - if (pkt.chunks.length < 1) - throw new Error(`Catchup chunks must not be empty`) - writer.writeString(pkt.world) - writer.writeUInt32(pkt.chunks.length) - for (const row of pkt.chunks) { - writer.writeInt32(row.chunkX) - writer.writeInt32(row.chunkZ) - writer.writeUInt64(row.timestamp) - } - } + export function encode(pkt: CatchupPacket, writer: BufWriter) { + if (pkt.chunks.length < 1) + throw new Error(`Catchup chunks must not be empty`); + writer.writeString(pkt.world); + writer.writeUInt32(pkt.chunks.length); + for (const row of pkt.chunks) { + writer.writeInt32(row.chunkX); + writer.writeInt32(row.chunkZ); + writer.writeUInt64(row.timestamp); + } + } } diff --git a/server/src/protocol/CatchupRequestPacket.ts b/server/src/protocol/CatchupRequestPacket.ts index f95e3815..a14ddc86 100644 --- a/server/src/protocol/CatchupRequestPacket.ts +++ b/server/src/protocol/CatchupRequestPacket.ts @@ -1,23 +1,23 @@ -import { type CatchupChunk } from '../model' -import { BufReader } from './BufReader' +import { type CatchupChunk } from "../model"; +import { BufReader } from "./BufReader"; export interface CatchupRequestPacket { - type: 'CatchupRequest' - world: string - chunks: CatchupChunk[] + type: "CatchupRequest"; + world: string; + chunks: CatchupChunk[]; } export namespace CatchupRequestPacket { - export function decode(reader: BufReader): CatchupRequestPacket { - const world = reader.readString() - const chunks: CatchupChunk[] = new Array(reader.readUInt32()) - for (let i = 0; i < chunks.length; i++) { - chunks[i] = { - chunkX: reader.readInt32(), - chunkZ: reader.readInt32(), - timestamp: reader.readUInt64(), - } - } - return { type: 'CatchupRequest', world, chunks } - } + export function decode(reader: BufReader): CatchupRequestPacket { + const world = reader.readString(); + const chunks: CatchupChunk[] = new Array(reader.readUInt32()); + for (let i = 0; i < chunks.length; i++) { + chunks[i] = { + chunkX: reader.readInt32(), + chunkZ: reader.readInt32(), + timestamp: reader.readUInt64(), + }; + } + return { type: "CatchupRequest", world, chunks }; + } } diff --git a/server/src/protocol/ChunkTilePacket.ts b/server/src/protocol/ChunkTilePacket.ts index 99d96778..eee9f326 100644 --- a/server/src/protocol/ChunkTilePacket.ts +++ b/server/src/protocol/ChunkTilePacket.ts @@ -1,39 +1,39 @@ -import { BufReader } from './BufReader' -import { BufWriter } from './BufWriter' -import { SHA1_HASH_LENGTH } from '../constants' +import { BufReader } from "./BufReader"; +import { BufWriter } from "./BufWriter"; +import { SHA1_HASH_LENGTH } from "../constants"; export interface ChunkTilePacket { - type: 'ChunkTile' - world: string - chunk_x: number - chunk_z: number - ts: number - data: { version: number; hash: Buffer; data: Buffer } + type: "ChunkTile"; + world: string; + chunk_x: number; + chunk_z: number; + ts: number; + data: { version: number; hash: Buffer; data: Buffer }; } export namespace ChunkTilePacket { - export function decode(reader: BufReader): ChunkTilePacket { - return { - type: 'ChunkTile', - world: reader.readString(), - chunk_x: reader.readInt32(), - chunk_z: reader.readInt32(), - ts: reader.readUInt64(), - data: { - version: reader.readUInt16(), - hash: reader.readBufLen(SHA1_HASH_LENGTH), - data: reader.readRemainder(), - }, - } - } + export function decode(reader: BufReader): ChunkTilePacket { + return { + type: "ChunkTile", + world: reader.readString(), + chunk_x: reader.readInt32(), + chunk_z: reader.readInt32(), + ts: reader.readUInt64(), + data: { + version: reader.readUInt16(), + hash: reader.readBufLen(SHA1_HASH_LENGTH), + data: reader.readRemainder(), + }, + }; + } - export function encode(pkt: ChunkTilePacket, writer: BufWriter) { - writer.writeString(pkt.world) - writer.writeInt32(pkt.chunk_x) - writer.writeInt32(pkt.chunk_z) - writer.writeUInt64(pkt.ts) - writer.writeUInt16(pkt.data.version) - writer.writeBufRaw(pkt.data.hash) - writer.writeBufRaw(pkt.data.data) // XXX do we need to prefix with length? - } + export function encode(pkt: ChunkTilePacket, writer: BufWriter) { + writer.writeString(pkt.world); + writer.writeInt32(pkt.chunk_x); + writer.writeInt32(pkt.chunk_z); + writer.writeUInt64(pkt.ts); + writer.writeUInt16(pkt.data.version); + writer.writeBufRaw(pkt.data.hash); + writer.writeBufRaw(pkt.data.data); // XXX do we need to prefix with length? + } } diff --git a/server/src/protocol/EncryptionRequestPacket.ts b/server/src/protocol/EncryptionRequestPacket.ts index 53879592..148e4212 100644 --- a/server/src/protocol/EncryptionRequestPacket.ts +++ b/server/src/protocol/EncryptionRequestPacket.ts @@ -1,23 +1,23 @@ -import { BufReader } from './BufReader' -import { BufWriter } from './BufWriter' +import { BufReader } from "./BufReader"; +import { BufWriter } from "./BufWriter"; export interface EncryptionRequestPacket { - type: 'EncryptionRequest' - publicKey: Buffer - verifyToken: Buffer + type: "EncryptionRequest"; + publicKey: Buffer; + verifyToken: Buffer; } export namespace EncryptionRequestPacket { - export function decode(reader: BufReader): EncryptionRequestPacket { - return { - type: 'EncryptionRequest', - publicKey: reader.readBufWithLen(), - verifyToken: reader.readBufWithLen(), - } - } + export function decode(reader: BufReader): EncryptionRequestPacket { + return { + type: "EncryptionRequest", + publicKey: reader.readBufWithLen(), + verifyToken: reader.readBufWithLen(), + }; + } - export function encode(pkt: EncryptionRequestPacket, writer: BufWriter) { - writer.writeBufWithLen(pkt.publicKey) - writer.writeBufWithLen(pkt.verifyToken) - } + export function encode(pkt: EncryptionRequestPacket, writer: BufWriter) { + writer.writeBufWithLen(pkt.publicKey); + writer.writeBufWithLen(pkt.verifyToken); + } } diff --git a/server/src/protocol/EncryptionResponsePacket.ts b/server/src/protocol/EncryptionResponsePacket.ts index 6949c557..e17adc5f 100644 --- a/server/src/protocol/EncryptionResponsePacket.ts +++ b/server/src/protocol/EncryptionResponsePacket.ts @@ -1,25 +1,25 @@ -import { BufReader } from './BufReader' -import { BufWriter } from './BufWriter' +import { BufReader } from "./BufReader"; +import { BufWriter } from "./BufWriter"; export interface EncryptionResponsePacket { - type: 'EncryptionResponse' - /** encrypted with server's public key */ - sharedSecret: Buffer - /** encrypted with server's public key */ - verifyToken: Buffer + type: "EncryptionResponse"; + /** encrypted with server's public key */ + sharedSecret: Buffer; + /** encrypted with server's public key */ + verifyToken: Buffer; } export namespace EncryptionResponsePacket { - export function decode(reader: BufReader): EncryptionResponsePacket { - return { - type: 'EncryptionResponse', - sharedSecret: reader.readBufWithLen(), - verifyToken: reader.readBufWithLen(), - } - } + export function decode(reader: BufReader): EncryptionResponsePacket { + return { + type: "EncryptionResponse", + sharedSecret: reader.readBufWithLen(), + verifyToken: reader.readBufWithLen(), + }; + } - export function encode(pkt: EncryptionResponsePacket, writer: BufWriter) { - writer.writeBufWithLen(pkt.sharedSecret) - writer.writeBufWithLen(pkt.verifyToken) - } + export function encode(pkt: EncryptionResponsePacket, writer: BufWriter) { + writer.writeBufWithLen(pkt.sharedSecret); + writer.writeBufWithLen(pkt.verifyToken); + } } diff --git a/server/src/protocol/HandshakePacket.ts b/server/src/protocol/HandshakePacket.ts index 470b2829..32bd4b82 100644 --- a/server/src/protocol/HandshakePacket.ts +++ b/server/src/protocol/HandshakePacket.ts @@ -1,22 +1,22 @@ -import { BufReader } from './BufReader' -import { BufWriter } from './BufWriter' +import { BufReader } from "./BufReader"; +import { BufWriter } from "./BufWriter"; export interface HandshakePacket { - type: 'Handshake' - modVersion: string - mojangName: string - gameAddress: string - world: string + type: "Handshake"; + modVersion: string; + mojangName: string; + gameAddress: string; + world: string; } export namespace HandshakePacket { - export function decode(reader: BufReader): HandshakePacket { - return { - type: 'Handshake', - modVersion: reader.readString(), - mojangName: reader.readString(), - gameAddress: reader.readString(), - world: reader.readString(), - } - } + export function decode(reader: BufReader): HandshakePacket { + return { + type: "Handshake", + modVersion: reader.readString(), + mojangName: reader.readString(), + gameAddress: reader.readString(), + world: reader.readString(), + }; + } } diff --git a/server/src/protocol/RegionCatchupPacket.ts b/server/src/protocol/RegionCatchupPacket.ts index 684b78ee..13890d9b 100644 --- a/server/src/protocol/RegionCatchupPacket.ts +++ b/server/src/protocol/RegionCatchupPacket.ts @@ -1,23 +1,23 @@ -import { BufReader } from './BufReader' -import { type Pos2D } from '../model' +import { BufReader } from "./BufReader"; +import { type Pos2D } from "../model"; export interface RegionCatchupPacket { - type: 'RegionCatchup' - world: string - regions: Pos2D[] + type: "RegionCatchup"; + world: string; + regions: Pos2D[]; } export namespace RegionCatchupPacket { - export function decode(reader: BufReader): RegionCatchupPacket { - let world = reader.readString() - const len = reader.readInt16() - const regions: Pos2D[] = [] - for (let i = 0; i < len; i++) { - regions.push({ - x: reader.readInt16(), - z: reader.readInt16(), - }) - } - return { type: 'RegionCatchup', world, regions } - } + export function decode(reader: BufReader): RegionCatchupPacket { + let world = reader.readString(); + const len = reader.readInt16(); + const regions: Pos2D[] = []; + for (let i = 0; i < len; i++) { + regions.push({ + x: reader.readInt16(), + z: reader.readInt16(), + }); + } + return { type: "RegionCatchup", world, regions }; + } } diff --git a/server/src/protocol/RegionTimestampsPacket.ts b/server/src/protocol/RegionTimestampsPacket.ts index d68bc8ee..e99a151c 100644 --- a/server/src/protocol/RegionTimestampsPacket.ts +++ b/server/src/protocol/RegionTimestampsPacket.ts @@ -1,22 +1,22 @@ -import { BufWriter } from './BufWriter' -import { CatchupRegion } from '../model' +import { BufWriter } from "./BufWriter"; +import { CatchupRegion } from "../model"; export interface RegionTimestampsPacket { - type: 'RegionTimestamps' - world: string - regions: Array + type: "RegionTimestamps"; + world: string; + regions: Array; } export namespace RegionTimestampsPacket { - export function encode(pkt: RegionTimestampsPacket, writer: BufWriter) { - writer.writeString(pkt.world) - writer.writeInt16(pkt.regions.length) - console.log('Sending regions ' + JSON.stringify(pkt.regions)) - for (let i = 0; i < pkt.regions.length; i++) { - let region = pkt.regions[i] - writer.writeInt16(region.regionX) - writer.writeInt16(region.regionZ) - writer.writeInt64(region.timestamp) - } - } + export function encode(pkt: RegionTimestampsPacket, writer: BufWriter) { + writer.writeString(pkt.world); + writer.writeInt16(pkt.regions.length); + console.log("Sending regions " + JSON.stringify(pkt.regions)); + for (let i = 0; i < pkt.regions.length; i++) { + let region = pkt.regions[i]; + writer.writeInt16(region.regionX); + writer.writeInt16(region.regionZ); + writer.writeInt64(region.timestamp); + } + } } diff --git a/server/src/protocol/index.ts b/server/src/protocol/index.ts index 4d4a3fc3..da615fb8 100644 --- a/server/src/protocol/index.ts +++ b/server/src/protocol/index.ts @@ -1,75 +1,75 @@ -import { BufReader } from './BufReader' -import { BufWriter } from './BufWriter' -import { ChunkTilePacket } from './ChunkTilePacket' -import { EncryptionRequestPacket } from './EncryptionRequestPacket' -import { EncryptionResponsePacket } from './EncryptionResponsePacket' -import { HandshakePacket } from './HandshakePacket' -import { CatchupPacket } from './CatchupPacket' -import { CatchupRequestPacket } from './CatchupRequestPacket' -import { RegionTimestampsPacket } from './RegionTimestampsPacket' -import { RegionCatchupPacket } from './RegionCatchupPacket' +import { BufReader } from "./BufReader"; +import { BufWriter } from "./BufWriter"; +import { ChunkTilePacket } from "./ChunkTilePacket"; +import { EncryptionRequestPacket } from "./EncryptionRequestPacket"; +import { EncryptionResponsePacket } from "./EncryptionResponsePacket"; +import { HandshakePacket } from "./HandshakePacket"; +import { CatchupPacket } from "./CatchupPacket"; +import { CatchupRequestPacket } from "./CatchupRequestPacket"; +import { RegionTimestampsPacket } from "./RegionTimestampsPacket"; +import { RegionCatchupPacket } from "./RegionCatchupPacket"; export type ClientPacket = - | ChunkTilePacket - | EncryptionResponsePacket - | HandshakePacket - | CatchupRequestPacket - | RegionCatchupPacket + | ChunkTilePacket + | EncryptionResponsePacket + | HandshakePacket + | CatchupRequestPacket + | RegionCatchupPacket; export type ServerPacket = - | ChunkTilePacket - | EncryptionRequestPacket - | CatchupPacket - | RegionTimestampsPacket + | ChunkTilePacket + | EncryptionRequestPacket + | CatchupPacket + | RegionTimestampsPacket; export const packetIds = [ - 'ERROR:pkt0', - 'Handshake', - 'EncryptionRequest', - 'EncryptionResponse', - 'ChunkTile', - 'Catchup', - 'CatchupRequest', - 'RegionTimestamps', - 'RegionCatchup', -] + "ERROR:pkt0", + "Handshake", + "EncryptionRequest", + "EncryptionResponse", + "ChunkTile", + "Catchup", + "CatchupRequest", + "RegionTimestamps", + "RegionCatchup", +]; -export function getPacketId(type: ServerPacket['type']) { - const id = packetIds.indexOf(type) - if (id === -1) throw new Error(`Unknown packet type ${type}`) - return id +export function getPacketId(type: ServerPacket["type"]) { + const id = packetIds.indexOf(type); + if (id === -1) throw new Error(`Unknown packet type ${type}`); + return id; } export function decodePacket(reader: BufReader): ClientPacket { - const packetType = reader.readUInt8() - switch (packetIds[packetType]) { - case 'ChunkTile': - return ChunkTilePacket.decode(reader) - case 'Handshake': - return HandshakePacket.decode(reader) - case 'EncryptionResponse': - return EncryptionResponsePacket.decode(reader) - case 'CatchupRequest': - return CatchupRequestPacket.decode(reader) - case 'RegionCatchup': - return RegionCatchupPacket.decode(reader) - default: - throw new Error(`Unknown packet type ${packetType}`) - } + const packetType = reader.readUInt8(); + switch (packetIds[packetType]) { + case "ChunkTile": + return ChunkTilePacket.decode(reader); + case "Handshake": + return HandshakePacket.decode(reader); + case "EncryptionResponse": + return EncryptionResponsePacket.decode(reader); + case "CatchupRequest": + return CatchupRequestPacket.decode(reader); + case "RegionCatchup": + return RegionCatchupPacket.decode(reader); + default: + throw new Error(`Unknown packet type ${packetType}`); + } } export function encodePacket(pkt: ServerPacket, writer: BufWriter): void { - writer.writeUInt8(getPacketId(pkt.type)) - switch (pkt.type) { - case 'ChunkTile': - return ChunkTilePacket.encode(pkt, writer) - case 'Catchup': - return CatchupPacket.encode(pkt, writer) - case 'EncryptionRequest': - return EncryptionRequestPacket.encode(pkt, writer) - case 'RegionTimestamps': - return RegionTimestampsPacket.encode(pkt, writer) - default: - throw new Error(`Unknown packet type ${(pkt as any).type}`) - } + writer.writeUInt8(getPacketId(pkt.type)); + switch (pkt.type) { + case "ChunkTile": + return ChunkTilePacket.encode(pkt, writer); + case "Catchup": + return CatchupPacket.encode(pkt, writer); + case "EncryptionRequest": + return EncryptionRequestPacket.encode(pkt, writer); + case "RegionTimestamps": + return RegionTimestampsPacket.encode(pkt, writer); + default: + throw new Error(`Unknown packet type ${(pkt as any).type}`); + } } diff --git a/server/src/server.ts b/server/src/server.ts index 7a7ff3c9..73e21bf4 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,327 +1,340 @@ -import crypto from 'crypto' -import net from 'net' -import { Main } from './main' -import type { ClientPacket, ServerPacket } from './protocol' -import { decodePacket, encodePacket } from './protocol' -import { BufReader } from './protocol/BufReader' -import { BufWriter } from './protocol/BufWriter' -import { EncryptionResponsePacket } from './protocol/EncryptionResponsePacket' -import { HandshakePacket } from './protocol/HandshakePacket' -import { SUPPORTED_VERSIONS } from './constants' +import crypto from "crypto"; +import net from "net"; +import { Main } from "./main"; +import type { ClientPacket, ServerPacket } from "./protocol"; +import { decodePacket, encodePacket } from "./protocol"; +import { BufReader } from "./protocol/BufReader"; +import { BufWriter } from "./protocol/BufWriter"; +import { EncryptionResponsePacket } from "./protocol/EncryptionResponsePacket"; +import { HandshakePacket } from "./protocol/HandshakePacket"; +import { SUPPORTED_VERSIONS } from "./constants"; -const { PORT = '12312', HOST = '127.0.0.1' } = process.env +const { PORT = "12312", HOST = "127.0.0.1" } = process.env; -type ProtocolHandler = Main // TODO cleanup +type ProtocolHandler = Main; // TODO cleanup export class TcpServer { - server: net.Server - clients: Record = {} - - keyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 1024 }) - // precomputed for networking - publicKeyBuffer = this.keyPair.publicKey.export({ - type: 'spki', - format: 'der', - }) - - constructor(readonly handler: ProtocolHandler) { - this.server = net.createServer({}, (socket) => { - const client = new TcpClient(socket, this, handler) - this.clients[client.id] = client - socket.on('close', () => delete this.clients[client.id]) - }) - - this.server.on('error', (err: Error) => { - console.error('[TcpServer] Error:', err) - this.server.close() - }) - - this.server.listen({ port: PORT, hostname: HOST }, () => { - console.log('[TcpServer] Listening on', HOST, PORT) - }) - } - - decrypt(buf: Buffer) { - return crypto.privateDecrypt( - { - key: this.keyPair.privateKey, - padding: crypto.constants.RSA_PKCS1_PADDING, - }, - buf, - ) - } + server: net.Server; + clients: Record = {}; + + keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 1024 }); + // precomputed for networking + publicKeyBuffer = this.keyPair.publicKey.export({ + type: "spki", + format: "der", + }); + + constructor(readonly handler: ProtocolHandler) { + this.server = net.createServer({}, (socket) => { + const client = new TcpClient(socket, this, handler); + this.clients[client.id] = client; + socket.on("close", () => delete this.clients[client.id]); + }); + + this.server.on("error", (err: Error) => { + console.error("[TcpServer] Error:", err); + this.server.close(); + }); + + this.server.listen({ port: PORT, hostname: HOST }, () => { + console.log("[TcpServer] Listening on", HOST, PORT); + }); + } + + decrypt(buf: Buffer) { + return crypto.privateDecrypt( + { + key: this.keyPair.privateKey, + padding: crypto.constants.RSA_PKCS1_PADDING, + }, + buf, + ); + } } -let nextClientId = 1 +let nextClientId = 1; /** Prefixes packets with their length (UInt32BE); * handles Mojang authentication */ export class TcpClient { - readonly id = nextClientId++ - /** contains mojang name once logged in */ - name = 'Client' + this.id - - modVersion: string | undefined - gameAddress: string | undefined - uuid: string | undefined - mcName: string | undefined - world: string | undefined - - /** prevent Out of Memory when client sends a large packet */ - maxFrameSize = 2 ** 15 - - /** sent by client during handshake */ - private claimedMojangName?: string - private verifyToken?: Buffer - /** we need to wait for the mojang auth response - * before we can en/decrypt packets following the handshake */ - private cryptoPromise?: Promise<{ - cipher: crypto.Cipher - decipher: crypto.Decipher - }> - - constructor( - private socket: net.Socket, - private server: TcpServer, - private handler: ProtocolHandler, - ) { - this.log('Connected from', socket.remoteAddress) - handler.handleClientConnected(this) - - /** Accumulates received data, containing none, one, or multiple frames; the last frame may be partial only. */ - let accBuf: Buffer = Buffer.alloc(0) - - socket.on('data', async (data: Buffer) => { - try { - if (this.cryptoPromise) { - const { decipher } = await this.cryptoPromise - data = decipher.update(data) - } - - // creating a new buffer every time is fine in our case, because we expect most frames to be large - accBuf = Buffer.concat([accBuf, data]) - - // we may receive multiple frames in one call - while (true) { - if (accBuf.length <= 4) return // wait for more data - const frameSize = accBuf.readUInt32BE() - - // prevent Out of Memory - if (frameSize > this.maxFrameSize) { - return this.kick( - 'Frame too large: ' + frameSize + ' have ' + accBuf.length, - ) - } - - if (accBuf.length < 4 + frameSize) return // wait for more data - - const frameReader = new BufReader(accBuf) - frameReader.readUInt32() // skip frame size - let pktBuf = frameReader.readBufLen(frameSize) - accBuf = frameReader.readRemainder() - - const reader = new BufReader(pktBuf) - - try { - const packet = decodePacket(reader) - await this.handlePacketReceived(packet) - } catch (err) { - this.warn(err) - return this.kick('Error in packet handler') - } - } - } catch (err) { - this.warn(err) - return this.kick('Error in data handler') - } - }) - - socket.on('close', (hadError: boolean) => { - this.log('Closed.', { hadError }) - }) - - socket.on('end', () => { - // This event is called when the other end signals the end of transmission, meaning this client is - // still writeable, but no longer readable. In this situation we just want to close the socket. - // https://nodejs.org/dist/latest-v18.x/docs/api/net.html#event-end - this.kick('Ended') - }) - - socket.on('timeout', () => { - // As per the docs, the socket needs to be manually closed. - // https://nodejs.org/dist/latest-v18.x/docs/api/net.html#event-timeout - this.kick('Timed out') - }) - - socket.on('error', (err: Error) => { - this.warn('Error:', err) - this.kick('Socket error') - }) - } - - private async handlePacketReceived(pkt: ClientPacket) { - if (!this.uuid) { - // not authenticated yet - switch (pkt.type) { - case 'Handshake': - return await this.handleHandshakePacket(pkt) - case 'EncryptionResponse': - return await this.handleEncryptionResponsePacket(pkt) - } - throw new Error(`Packet ${pkt.type} from unauth'd client ${this.id}`) - } else { - return await this.handler.handleClientPacketReceived(this, pkt) - } - } - - kick(internalReason: string) { - this.log(`Kicking:`, internalReason) - this.socket.destroy() - } - - async send(pkt: ServerPacket) { - if (!this.cryptoPromise) { - this.debug('Not encrypted, dropping packet', pkt.type) - return - } - if (!this.uuid) { - this.debug('Not authenticated, dropping packet', pkt.type) - return - } - this.debug(this.mcName + ' -> ' + pkt.type) - await this.sendInternal(pkt, true) - } - - private async sendInternal(pkt: ServerPacket, doCrypto = false) { - if (!this.socket.writable) - return this.debug('Socket closed, dropping', pkt.type) - if (doCrypto && !this.cryptoPromise) - throw new Error(`Can't encrypt: handshake not finished`) - - const writer = new BufWriter() // TODO size hint - writer.writeUInt32(0) // set later, but reserve space in buffer - encodePacket(pkt, writer) - let buf = writer.getBuffer() - buf.writeUInt32BE(buf.length - 4, 0) // write into space reserved above - - if (doCrypto) { - const { cipher } = await this.cryptoPromise! - buf = cipher!.update(buf) - } - - this.socket.write(buf) - } - - private async handleHandshakePacket(packet: HandshakePacket) { - if (this.cryptoPromise) throw new Error(`Already authenticated`) - if (this.verifyToken) throw new Error(`Encryption already started`) - - if (!SUPPORTED_VERSIONS.has(packet.modVersion)) { - this.kick( - 'Connected with unsupported version [' + packet.modVersion + ']', - ) - return - } - - this.gameAddress = packet.gameAddress - this.claimedMojangName = packet.mojangName - this.world = packet.world - this.verifyToken = crypto.randomBytes(4) - - await this.sendInternal({ - type: 'EncryptionRequest', - publicKey: this.server.publicKeyBuffer, - verifyToken: this.verifyToken, - }) - } - - private async handleEncryptionResponsePacket(pkt: EncryptionResponsePacket) { - if (this.cryptoPromise) throw new Error(`Already authenticated`) - if (!this.claimedMojangName) - throw new Error(`Encryption has not started: no mojangName`) - if (!this.verifyToken) - throw new Error(`Encryption has not started: no verifyToken`) - - const verifyToken = this.server.decrypt(pkt.verifyToken) - if (!this.verifyToken.equals(verifyToken)) { - throw new Error( - `verifyToken mismatch: got ${verifyToken} expected ${this.verifyToken}`, - ) - } - - const secret = this.server.decrypt(pkt.sharedSecret) - - const shaHex = crypto - .createHash('sha1') - .update(secret) - .update(this.server.publicKeyBuffer) - .digest() - .toString('hex') - - this.cryptoPromise = fetchHasJoined({ - username: this.claimedMojangName, - shaHex, - }).then(async (mojangAuth) => { - if (!mojangAuth?.uuid) { - this.kick(`Mojang auth failed`) - throw new Error(`Mojang auth failed`) - } - - this.log('Authenticated as', mojangAuth) - - this.uuid = mojangAuth.uuid - this.mcName = mojangAuth.name - this.name += ':' + mojangAuth.name - - return { - cipher: crypto.createCipheriv('aes-128-cfb8', secret, secret), - decipher: crypto.createDecipheriv('aes-128-cfb8', secret, secret), - } - }) - - await this.cryptoPromise.then(async () => { - await this.handler.handleClientAuthenticated(this) - }) - } - - debug(...args: any[]) { - if (process.env.NODE_ENV === 'production') return - console.debug(`[${this.name}]`, ...args) - } - - log(...args: any[]) { - console.log(`[${this.name}]`, ...args) - } - - warn(...args: any[]) { - console.error(`[${this.name}]`, ...args) - } + readonly id = nextClientId++; + /** contains mojang name once logged in */ + name = "Client" + this.id; + + modVersion: string | undefined; + gameAddress: string | undefined; + uuid: string | undefined; + mcName: string | undefined; + world: string | undefined; + + /** prevent Out of Memory when client sends a large packet */ + maxFrameSize = 2 ** 15; + + /** sent by client during handshake */ + private claimedMojangName?: string; + private verifyToken?: Buffer; + /** we need to wait for the mojang auth response + * before we can en/decrypt packets following the handshake */ + private cryptoPromise?: Promise<{ + cipher: crypto.Cipher; + decipher: crypto.Decipher; + }>; + + constructor( + private socket: net.Socket, + private server: TcpServer, + private handler: ProtocolHandler, + ) { + this.log("Connected from", socket.remoteAddress); + handler.handleClientConnected(this); + + /** Accumulates received data, containing none, one, or multiple frames; the last frame may be partial only. */ + let accBuf: Buffer = Buffer.alloc(0); + + socket.on("data", async (data: Buffer) => { + try { + if (this.cryptoPromise) { + const { decipher } = await this.cryptoPromise; + data = decipher.update(data); + } + + // creating a new buffer every time is fine in our case, because we expect most frames to be large + accBuf = Buffer.concat([accBuf, data]); + + // we may receive multiple frames in one call + while (true) { + if (accBuf.length <= 4) return; // wait for more data + const frameSize = accBuf.readUInt32BE(); + + // prevent Out of Memory + if (frameSize > this.maxFrameSize) { + return this.kick( + "Frame too large: " + + frameSize + + " have " + + accBuf.length, + ); + } + + if (accBuf.length < 4 + frameSize) return; // wait for more data + + const frameReader = new BufReader(accBuf); + frameReader.readUInt32(); // skip frame size + let pktBuf = frameReader.readBufLen(frameSize); + accBuf = frameReader.readRemainder(); + + const reader = new BufReader(pktBuf); + + try { + const packet = decodePacket(reader); + await this.handlePacketReceived(packet); + } catch (err) { + this.warn(err); + return this.kick("Error in packet handler"); + } + } + } catch (err) { + this.warn(err); + return this.kick("Error in data handler"); + } + }); + + socket.on("close", (hadError: boolean) => { + this.log("Closed.", { hadError }); + }); + + socket.on("end", () => { + // This event is called when the other end signals the end of transmission, meaning this client is + // still writeable, but no longer readable. In this situation we just want to close the socket. + // https://nodejs.org/dist/latest-v18.x/docs/api/net.html#event-end + this.kick("Ended"); + }); + + socket.on("timeout", () => { + // As per the docs, the socket needs to be manually closed. + // https://nodejs.org/dist/latest-v18.x/docs/api/net.html#event-timeout + this.kick("Timed out"); + }); + + socket.on("error", (err: Error) => { + this.warn("Error:", err); + this.kick("Socket error"); + }); + } + + private async handlePacketReceived(pkt: ClientPacket) { + if (!this.uuid) { + // not authenticated yet + switch (pkt.type) { + case "Handshake": + return await this.handleHandshakePacket(pkt); + case "EncryptionResponse": + return await this.handleEncryptionResponsePacket(pkt); + } + throw new Error( + `Packet ${pkt.type} from unauth'd client ${this.id}`, + ); + } else { + return await this.handler.handleClientPacketReceived(this, pkt); + } + } + + kick(internalReason: string) { + this.log(`Kicking:`, internalReason); + this.socket.destroy(); + } + + async send(pkt: ServerPacket) { + if (!this.cryptoPromise) { + this.debug("Not encrypted, dropping packet", pkt.type); + return; + } + if (!this.uuid) { + this.debug("Not authenticated, dropping packet", pkt.type); + return; + } + this.debug(this.mcName + " -> " + pkt.type); + await this.sendInternal(pkt, true); + } + + private async sendInternal(pkt: ServerPacket, doCrypto = false) { + if (!this.socket.writable) + return this.debug("Socket closed, dropping", pkt.type); + if (doCrypto && !this.cryptoPromise) + throw new Error(`Can't encrypt: handshake not finished`); + + const writer = new BufWriter(); // TODO size hint + writer.writeUInt32(0); // set later, but reserve space in buffer + encodePacket(pkt, writer); + let buf = writer.getBuffer(); + buf.writeUInt32BE(buf.length - 4, 0); // write into space reserved above + + if (doCrypto) { + const { cipher } = await this.cryptoPromise!; + buf = cipher!.update(buf); + } + + this.socket.write(buf); + } + + private async handleHandshakePacket(packet: HandshakePacket) { + if (this.cryptoPromise) throw new Error(`Already authenticated`); + if (this.verifyToken) throw new Error(`Encryption already started`); + + if (!SUPPORTED_VERSIONS.has(packet.modVersion)) { + this.kick( + "Connected with unsupported version [" + + packet.modVersion + + "]", + ); + return; + } + + this.gameAddress = packet.gameAddress; + this.claimedMojangName = packet.mojangName; + this.world = packet.world; + this.verifyToken = crypto.randomBytes(4); + + await this.sendInternal({ + type: "EncryptionRequest", + publicKey: this.server.publicKeyBuffer, + verifyToken: this.verifyToken, + }); + } + + private async handleEncryptionResponsePacket( + pkt: EncryptionResponsePacket, + ) { + if (this.cryptoPromise) throw new Error(`Already authenticated`); + if (!this.claimedMojangName) + throw new Error(`Encryption has not started: no mojangName`); + if (!this.verifyToken) + throw new Error(`Encryption has not started: no verifyToken`); + + const verifyToken = this.server.decrypt(pkt.verifyToken); + if (!this.verifyToken.equals(verifyToken)) { + throw new Error( + `verifyToken mismatch: got ${verifyToken} expected ${this.verifyToken}`, + ); + } + + const secret = this.server.decrypt(pkt.sharedSecret); + + const shaHex = crypto + .createHash("sha1") + .update(secret) + .update(this.server.publicKeyBuffer) + .digest() + .toString("hex"); + + this.cryptoPromise = fetchHasJoined({ + username: this.claimedMojangName, + shaHex, + }).then(async (mojangAuth) => { + if (!mojangAuth?.uuid) { + this.kick(`Mojang auth failed`); + throw new Error(`Mojang auth failed`); + } + + this.log("Authenticated as", mojangAuth); + + this.uuid = mojangAuth.uuid; + this.mcName = mojangAuth.name; + this.name += ":" + mojangAuth.name; + + return { + cipher: crypto.createCipheriv("aes-128-cfb8", secret, secret), + decipher: crypto.createDecipheriv( + "aes-128-cfb8", + secret, + secret, + ), + }; + }); + + await this.cryptoPromise.then(async () => { + await this.handler.handleClientAuthenticated(this); + }); + } + + debug(...args: any[]) { + if (process.env.NODE_ENV === "production") return; + console.debug(`[${this.name}]`, ...args); + } + + log(...args: any[]) { + console.log(`[${this.name}]`, ...args); + } + + warn(...args: any[]) { + console.error(`[${this.name}]`, ...args); + } } async function fetchHasJoined(args: { - username: string - shaHex: string - clientIp?: string + username: string; + shaHex: string; + clientIp?: string; }) { - const { username, shaHex, clientIp } = args - - // if auth is disabled, return a "usable" item - if ('DISABLE_AUTH' in process.env) - return { name: username, uuid: `AUTH-DISABLED-${username}` } - - let url = `https://sessionserver.mojang.com/session/minecraft/hasJoined?username=${username}&serverId=${shaHex}` - if (clientIp) url += `&ip=${clientIp}` - const res = await fetch(url) - try { - if (res.status === 204) return null - let { id, name } = (await res.json()) as { id: string; name: string } - const uuid = id.replace( - /^(........)-?(....)-?(....)-?(....)-?(............)$/, - '$1-$2-$3-$4-$5', - ) - return { uuid, name } - } catch (err) { - console.error(res) - throw err - } + const { username, shaHex, clientIp } = args; + + // if auth is disabled, return a "usable" item + if ("DISABLE_AUTH" in process.env) + return { name: username, uuid: `AUTH-DISABLED-${username}` }; + + let url = `https://sessionserver.mojang.com/session/minecraft/hasJoined?username=${username}&serverId=${shaHex}`; + if (clientIp) url += `&ip=${clientIp}`; + const res = await fetch(url); + try { + if (res.status === 204) return null; + let { id, name } = (await res.json()) as { id: string; name: string }; + const uuid = id.replace( + /^(........)-?(....)-?(....)-?(....)-?(............)$/, + "$1-$2-$3-$4-$5", + ); + return { uuid, name }; + } catch (err) { + console.error(res); + throw err; + } } diff --git a/server/tsconfig.json b/server/tsconfig.json index 1ce5b47f..a21b0116 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,22 +1,22 @@ { - "compilerOptions": { - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "experimentalDecorators": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "lib": ["esnext", "webworker"], - "module": "CommonJS", - "moduleResolution": "node", - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noImplicitAny": true, - "outDir": "dist", - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "target": "ESNext" - }, - "include": ["src"] + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["esnext", "webworker"], + "module": "CommonJS", + "moduleResolution": "node", + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "outDir": "dist", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ESNext" + }, + "include": ["src"] } diff --git a/server/yarn.lock b/server/yarn.lock index 5e976958..db76b2f1 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -31,13 +31,13 @@ base64-js@^1.3.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -better-sqlite3@^8.5.0: - version "8.5.0" - resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-8.5.0.tgz#b13d12d0e477be69191a39628fc8b723ee1c79d4" - integrity sha512-vbPcv/Hx5WYdyNg/NbcfyaBZyv9s/NVbxb7yCeC5Bq1pVocNxeL2tZmSu3Rlm4IEOTjYdGyzWQgyx0OSdORBzw== +better-sqlite3@^9.5.0: + version "9.6.0" + resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-9.6.0.tgz#b01e58ba7c48abcdc0383b8301206ee2ab81d271" + integrity sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ== dependencies: bindings "^1.5.0" - prebuild-install "^7.1.0" + prebuild-install "^7.1.1" bindings@^1.5.0: version "1.5.0" @@ -183,10 +183,10 @@ once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" -prebuild-install@^7.1.0: - version "7.1.1" - resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.1.tgz#de97d5b34a70a0c81334fd24641f2a1702352e45" - integrity sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw== +prebuild-install@^7.1.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.2.tgz#a5fd9986f5a6251fbc47e1e5c65de71e68c0a056" + integrity sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ== dependencies: detect-libc "^2.0.0" expand-template "^2.0.3"