From 4b465802d125a2c2140905f2d8a9999e4f3e4c7c Mon Sep 17 00:00:00 2001 From: q13x <84165981+WorldEditAxe@users.noreply.github.com> Date: Thu, 21 Mar 2024 00:02:55 -0700 Subject: [PATCH] Add skin caching & fix pure JS skin server --- .DS_Store | Bin 0 -> 6148 bytes src/config.ts | 16 +-- src/launcher_types.ts | 7 +- src/proxy/Protocol.ts | 37 ++++-- src/proxy/Proxy.ts | 18 ++- src/proxy/databases/DiskDB.ts | 52 ++++++++ src/proxy/ratelimit/BucketRatelimiter.ts | 24 +++- src/proxy/skins/EaglerSkins.ts | 71 +---------- src/proxy/skins/ImageEditor.ts | 89 +++++++++----- src/proxy/skins/SimpleRatelimit.ts | 75 ------------ src/proxy/skins/SkinServer.ts | 144 +++++++++++++++++++++++ 11 files changed, 334 insertions(+), 199 deletions(-) create mode 100644 .DS_Store create mode 100644 src/proxy/databases/DiskDB.ts delete mode 100644 src/proxy/skins/SimpleRatelimit.ts create mode 100644 src/proxy/skins/SkinServer.ts diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..83b63ba9ee0b5ee9c97e66222ad0d53f0a1db8f0 GIT binary patch literal 6148 zcmeHK%}T>S5Z<+|-BN@c6nYGJE!bj}f|n5M3mDOZN=-=7V9b^#F^5vfSzpK}@p+ut z-9U@Mqlle>-EVe&b~7Jje;8xj&BGyM7Gq36L*%H`2%2kMJ0=*Bt2x4;NasN;gPLjn zrU}2j#R8VIh{bIE`#*wt9HrS{@X2=?&DL&Pv_)6kdrz|PGC#{x*I(S?=t{~ssB}NL zj%LNw+CP^`=10kFp%S8S1|fGhQ4-3+m3b1TD%aBv(Gi`g)mtu4M<;#zcsO45?d5nh zLVdVeb;QBp(b?tnIetmxngZ3~(RmDW~?^s6(7%u+)gN Tpk1W{(nUZKLLD*i3k-Y#q`OMY literal 0 HcmV?d00001 diff --git a/src/config.ts b/src/config.ts index 0c1c34a..08734ba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,10 +4,6 @@ import { Config } from "./launcher_types.js"; export const config: Config = { - bridge: { - enabled: false, - motd: null, - }, adapter: { name: "EaglerProxy", bindHost: "0.0.0.0", @@ -16,6 +12,12 @@ export const config: Config = { useNatives: false, skinServer: { skinUrlWhitelist: undefined, + cache: { + useCache: true, + folderName: "skinCache", + skinCacheLifetime: 60 * 1000, + skinCachePruneInterval: 5000, + }, }, motd: true ? "FORWARD" @@ -30,7 +32,7 @@ export const config: Config = { http: 100, ws: 100, motd: 100, - skins: 1000, + skins: 100, // adjust as necessary skinsIp: 10000, connect: 100, }, @@ -41,8 +43,8 @@ export const config: Config = { originBlacklist: null, }, server: { - host: "0.0.0.0", - port: 25565, + host: "127.0.0.1", + port: 1111, }, tls: undefined, }, diff --git a/src/launcher_types.ts b/src/launcher_types.ts index 6dd319c..716e09a 100644 --- a/src/launcher_types.ts +++ b/src/launcher_types.ts @@ -1,5 +1,4 @@ export type Config = { - bridge: BridgeOptions; adapter: AdapterOptions; }; @@ -22,6 +21,12 @@ export type AdapterOptions = { useNatives?: boolean; skinServer: { skinUrlWhitelist?: string[]; + cache: { + useCache: boolean; + folderName?: string; + skinCacheLifetime?: number; + skinCachePruneInterval?: number; + }; }; origins: { allowOfflineDownloads: boolean; diff --git a/src/proxy/Protocol.ts b/src/proxy/Protocol.ts index 60d5ade..0064aa9 100644 --- a/src/proxy/Protocol.ts +++ b/src/proxy/Protocol.ts @@ -1,7 +1,4 @@ -import { - encodeULEB128 as _encodeVarInt, - decodeULEB128 as _decodeVarInt, -} from "@thi.ng/leb128"; +import { encodeULEB128 as _encodeVarInt, decodeULEB128 as _decodeVarInt } from "@thi.ng/leb128"; import { Enums } from "./Enums.js"; import { Util } from "./Util.js"; @@ -24,10 +21,7 @@ export namespace MineProtocol { return Buffer.from(_encodeVarInt(int)); } - export function readVarInt( - buff: Buffer, - offset?: number - ): ReadResult { + export function readVarInt(buff: Buffer, offset?: number): ReadResult { buff = offset ? buff.subarray(offset) : buff; const read = _decodeVarInt(buff), len = read[1]; @@ -38,16 +32,35 @@ export namespace MineProtocol { }; } + export function writeVarLong(long: number): Buffer { + return writeVarInt(long); + } + + export function readVarLong(buff: Buffer, offset?: number): ReadResult { + return readVarInt(buff, offset); + } + + export function writeBinary(data: Buffer): Buffer { + return Buffer.concat([writeVarInt(data.length), data]); + } + + export function readBinary(buff: Buffer, offset?: number): ReadResult { + buff = offset ? buff.subarray(offset) : buff; + const len = readVarInt(buff), + data = len.newBuffer.subarray(0, len.value); + return { + value: data, + newBuffer: len.newBuffer.subarray(len.value), + }; + } + export function writeString(str: string): Buffer { const bufferized = Buffer.from(str, "utf8"), len = writeVarInt(bufferized.length); return Buffer.concat([len, bufferized]); } - export function readString( - buff: Buffer, - offset?: number - ): ReadResult { + export function readString(buff: Buffer, offset?: number): ReadResult { buff = offset ? buff.subarray(offset) : buff; const len = readVarInt(buff), str = len.newBuffer.subarray(0, len.value).toString("utf8"); diff --git a/src/proxy/Proxy.ts b/src/proxy/Proxy.ts index b165d68..5dd1c85 100644 --- a/src/proxy/Proxy.ts +++ b/src/proxy/Proxy.ts @@ -26,6 +26,8 @@ import { CSChannelMessagePacket } from "./packets/channel/CSChannelMessage.js"; import { Constants, UPGRADE_REQUIRED_RESPONSE } from "./Constants.js"; import { PluginManager } from "./pluginLoader/PluginManager.js"; import ProxyRatelimitManager from "./ratelimit/ProxyRatelimitManager.js"; +import { ChatColor } from "../plugins/EagProxyAAS/types.js"; +import { SkinServer } from "./skins/SkinServer.js"; let instanceCount = 0; const chalk = new Chalk({ level: 2 }); @@ -42,7 +44,7 @@ export class Proxy extends EventEmitter { public config: Config["adapter"]; public wsServer: WebSocketServer; public httpServer: http.Server; - public skinServer: EaglerSkins.SkinServer; + public skinServer: SkinServer; public broadcastMotd?: Motd.MOTD; public ratelimit: ProxyRatelimitManager; @@ -95,7 +97,15 @@ export class Proxy extends EventEmitter { if (this.loaded) throw new Error("Can't initiate if proxy instance is already initialized or is being initialized!"); this.loaded = true; this.packetRegistry = await loadPackets(); - this.skinServer = new EaglerSkins.SkinServer(this, this.config.useNatives, this.config.skinServer.skinUrlWhitelist); + this.skinServer = new SkinServer( + this, + this.config.useNatives, + this.config.skinServer.cache.skinCachePruneInterval, + this.config.skinServer.cache.skinCacheLifetime, + this.config.skinServer.cache.folderName, + this.config.skinServer.cache.useCache, + this.config.skinServer.skinUrlWhitelist + ); global.PACKET_REGISTRY = this.packetRegistry; if (this.config.motd == "FORWARD") { this._pollServer(this.config.server.host, this.config.server.port); @@ -137,6 +147,10 @@ export class Proxy extends EventEmitter { this._logger.error(`Error was caught whilst trying to handle WebSocket upgrade! Error: ${err.stack ?? err}`); } }); + process.on("beforeExit", () => { + this._logger.info("Cleaning up before exiting..."); + this.players.forEach((plr) => plr.disconnect(ChatColor.YELLOW + "Proxy is shutting down.")); + }); this.ratelimit = new ProxyRatelimitManager(this.config.ratelimits); this.pluginManager.emit("proxyFinishLoading", this, this.pluginManager); this._logger.info(`Started WebSocket server and binded to ${this.config.bindHost} on port ${this.config.bindPort}.`); diff --git a/src/proxy/databases/DiskDB.ts b/src/proxy/databases/DiskDB.ts new file mode 100644 index 0000000..04d24df --- /dev/null +++ b/src/proxy/databases/DiskDB.ts @@ -0,0 +1,52 @@ +import path from "path"; +import fs from "fs/promises"; +import fss from "fs"; + +export default class DiskDB { + public folder: string; + static VALIDATION_REGEX = /^[0-9a-zA-Z_]+$/; + + nameGenerator: (k: string) => string; + encoder: (key: T) => Buffer; + decoder: (enc: Buffer) => T; + + constructor(folder: string, encoder: (key: T) => Buffer, decoder: (enc: Buffer) => T, nameGenerator: (k: string) => string) { + this.folder = path.isAbsolute(folder) ? folder : path.resolve(folder); + this.encoder = encoder; + this.decoder = decoder; + this.nameGenerator = nameGenerator; + if (!fss.existsSync(this.folder)) fss.mkdirSync(this.folder); + } + + public async filter(f: (v: T) => boolean) { + for (const file of await fs.readdir(this.folder)) { + if (!f(this.decoder(await fs.readFile(file)))) await fs.rm(file); + } + } + + public async get(k: string): Promise { + k = this.nameGenerator(k); + if (!DiskDB.VALIDATION_REGEX.test(k)) throw new InvalidKeyError("Invalid key, key can only consist of alphanumeric characters and _"); + const pth = path.join(this.folder, `${k}.data`); + try { + return this.decoder(await fs.readFile(pth)); + } catch (err) { + return null; + } + } + + public async set(k: string, v: T) { + k = this.nameGenerator(k); + if (!DiskDB.VALIDATION_REGEX.test(k)) throw new InvalidKeyError("Invalid key, key can only consist of alphanumeric characters and _"); + const pth = path.join(this.folder, `${k}.data`); + await fs.writeFile(pth, this.encoder(v)); + } +} + +class InvalidKeyError extends Error { + constructor(msg: string) { + super(`[InvalidKeyError] : ${msg}`); + this.name = "InvalidKeyError"; + Object.setPrototypeOf(this, InvalidKeyError); + } +} diff --git a/src/proxy/ratelimit/BucketRatelimiter.ts b/src/proxy/ratelimit/BucketRatelimiter.ts index 79c448d..6055d64 100644 --- a/src/proxy/ratelimit/BucketRatelimiter.ts +++ b/src/proxy/ratelimit/BucketRatelimiter.ts @@ -1,27 +1,35 @@ +import { Logger } from "../../logger.js"; + export default class BucketRateLimiter { public capacity: number; public refillsPerMin: number; public keyMap: Map; public static readonly GC_TOLERANCE: number = 50; + private sweeper: NodeJS.Timer; constructor(capacity: number, refillsPerMin: number) { this.capacity = capacity; this.refillsPerMin = refillsPerMin; this.keyMap = new Map(); + this.sweeper = setInterval(() => { + this.removeFull(); + }, 5000); + } + + public cleanUp() { + clearInterval(this.sweeper); } public consume(key: string, consumeTokens: number = 1): RateLimitData { - if (this.keyMap.size > BucketRateLimiter.GC_TOLERANCE) this.removeFull(); if (this.keyMap.has(key)) { const bucket = this.keyMap.get(key); - // refill bucket as needed const now = Date.now(); - if (now - bucket.lastRefillTime > 60000) { + if (now - bucket.lastRefillTime > 60000 && bucket.tokens < this.capacity) { const refillTimes = Math.floor((now - bucket.lastRefillTime) / 60000); - bucket.tokens += refillTimes * this.refillsPerMin; + bucket.tokens = Math.min(this.capacity, bucket.tokens + refillTimes * this.refillsPerMin); bucket.lastRefillTime = now - (refillTimes % 60000); - } + } else if (now - bucket.lastRefillTime > 60000 && bucket.tokens >= this.capacity) bucket.lastRefillTime = now; if (bucket.tokens >= consumeTokens) { bucket.tokens -= consumeTokens; @@ -93,7 +101,13 @@ export default class BucketRateLimiter { public removeFull() { let remove: string[] = []; + const now = Date.now(); this.keyMap.forEach((v, k) => { + if (now - v.lastRefillTime > 60000 && v.tokens < this.capacity) { + const refillTimes = Math.floor((now - v.lastRefillTime) / 60000); + v.tokens = Math.min(this.capacity, v.tokens + refillTimes * this.refillsPerMin); + v.lastRefillTime = now - (refillTimes % 60000); + } else if (now - v.lastRefillTime > 60000 && v.tokens >= this.capacity) v.lastRefillTime = now; if (v.tokens == this.capacity) { remove.push(k); } diff --git a/src/proxy/skins/EaglerSkins.ts b/src/proxy/skins/EaglerSkins.ts index bd9ce57..c3f8e7c 100644 --- a/src/proxy/skins/EaglerSkins.ts +++ b/src/proxy/skins/EaglerSkins.ts @@ -144,6 +144,11 @@ export namespace EaglerSkins { return this; } + export function writeClientFetchEaglerSkin(uuid: string | Buffer, url: string): Buffer { + uuid = typeof uuid == "string" ? Util.uuidStringToBuffer(uuid) : uuid; + return Buffer.concat([[Enums.EaglerSkinPacketId.CFetchSkinEaglerPlayerReq], uuid, [0x00], MineProtocol.writeString(url)].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); + } + export function writeServerFetchSkinResultCustomPacket(uuid: string | Buffer, skin: Buffer, downloaded: boolean): Buffer { uuid = typeof uuid == "string" ? Util.uuidStringToBuffer(uuid) : uuid; return Buffer.concat( @@ -168,72 +173,6 @@ export namespace EaglerSkins { return ret; } - export class SkinServer { - public allowedSkinDomains: string[]; - public proxy: Proxy; - public usingNative: boolean; - private _logger: Logger; - - constructor(proxy: Proxy, native: boolean, allowedSkinDomains?: string[]) { - this.allowedSkinDomains = allowedSkinDomains ?? ["textures.minecraft.net"]; - this.proxy = proxy ?? PROXY; - this.usingNative = native; - this._logger = new Logger("SkinServer"); - this._logger.info("Started EaglercraftX skin server."); - } - - public async handleRequest(packet: CSChannelMessagePacket, caller: Player, proxy: Proxy) { - if (packet.messageType == Enums.ChannelMessageType.SERVER) throw new Error("Server message was passed to client message handler!"); - else if (packet.channel != Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME) throw new Error("Cannot handle non-EaglerX skin channel messages!"); - - { - const rl = proxy.ratelimit.skinsConnection.consume(caller.username), - rlip = proxy.ratelimit.skinsIP.consume(caller.ws._socket.remoteAddress); - if (!rl.success || !rlip.success) return; - } - - switch (packet.data[0] as Enums.EaglerSkinPacketId) { - default: - throw new Error("Unknown operation!"); - break; - case Enums.EaglerSkinPacketId.CFetchSkinEaglerPlayerReq: - const parsedPacket_0 = EaglerSkins.readClientFetchEaglerSkinPacket(packet.data); - const player = this.proxy.fetchUserByUUID(parsedPacket_0.uuid); - if (player) { - if (player.skin.type == Enums.SkinType.BUILTIN) { - const response = new SCChannelMessagePacket(); - response.channel = Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME; - response.data = EaglerSkins.writeServerFetchSkinResultBuiltInPacket(player.uuid, player.skin.builtInSkin); - caller.write(response); - } else if (player.skin.type == Enums.SkinType.CUSTOM) { - const response = new SCChannelMessagePacket(); - response.channel = Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME; - response.data = EaglerSkins.writeServerFetchSkinResultCustomPacket(player.uuid, player.skin.skin, false); - caller.write(response); - } else this._logger.warn(`Player ${caller.username} attempted to fetch player ${player.uuid}'s skin, but their skin hasn't loaded yet!`); - } - break; - case Enums.EaglerSkinPacketId.CFetchSkinReq: - const parsedPacket_1 = EaglerSkins.readClientDownloadSkinRequestPacket(packet.data), - url = new URL(parsedPacket_1.url).hostname; - if (!this.allowedSkinDomains.some((domain) => Util.areDomainsEqual(domain, url))) { - this._logger.warn(`Player ${caller.username} tried to download a skin with a disallowed domain name(${url})!`); - break; - } - try { - const fetched = await EaglerSkins.downloadSkin(parsedPacket_1.url), - processed = this.usingNative ? await ImageEditor.toEaglerSkin(fetched) : await ImageEditor.toEaglerSkinJS(fetched), - response = new SCChannelMessagePacket(); - response.channel = Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME; - response.data = EaglerSkins.writeServerFetchSkinResultCustomPacket(parsedPacket_1.uuid, processed, true); - caller.write(response); - } catch (err) { - this._logger.warn(`Failed to fetch skin URL ${parsedPacket_1.url} for player ${caller.username}: ${err.stack ?? err}`); - } - } - } - } - export class EaglerSkin { owner: Player; type: Enums.SkinType; diff --git a/src/proxy/skins/ImageEditor.ts b/src/proxy/skins/ImageEditor.ts index e6b0067..d24cb87 100644 --- a/src/proxy/skins/ImageEditor.ts +++ b/src/proxy/skins/ImageEditor.ts @@ -2,10 +2,11 @@ import { Constants } from "../Constants.js"; import { Enums } from "../Enums.js"; import { MineProtocol } from "../Protocol.js"; import { Util } from "../Util.js"; +import Jimp from "jimp"; import fs from "fs/promises"; -let Jimp: Jimp = null; -type Jimp = any; +// let Jimp: Jimp = null; +// type Jimp = any; let sharp: any = null; type Sharp = any; @@ -16,16 +17,23 @@ export namespace ImageEditor { export async function loadLibraries(native: boolean) { if (loadedLibraries) return; if (native) sharp = (await import("sharp")).default; - else Jimp = (await import("jimp")).default; + else { + // Jimp = (await import("jimp")).default; + Jimp.appendConstructorOption( + "Custom Bitmap Constructor", + (args) => args[0] && args[0].width != null && args[0].height != null && args[0].data != null, + (res, rej, args) => { + this.bitmap = args[0]; + res(); + } + ); + } + loadedLibraries = true; } - export function writeClientFetchEaglerSkin(uuid: string | Buffer, url: string): Buffer { - uuid = typeof uuid == "string" ? Util.uuidStringToBuffer(uuid) : uuid; - return Buffer.concat([[Enums.EaglerSkinPacketId.CFetchSkinEaglerPlayerReq], uuid, [0x00], MineProtocol.writeString(url)].map((arr) => (arr instanceof Uint8Array ? arr : Buffer.from(arr)))); - } - export async function copyRawPixelsJS(imageIn: Jimp, imageOut: Jimp, dx1: number, dy1: number, dx2: number, dy2: number, sx1: number, sy1: number, sx2: number, sy2: number): Promise { + console.log(imageOut); if (dx1 > dx2) { return _copyRawPixelsJS(imageIn, imageOut, sx1, sy1, dx2, dy1, sx2 - sx1, sy2 - sy1, imageIn.getWidth(), imageOut.getWidth(), true); } else { @@ -33,35 +41,53 @@ export namespace ImageEditor { } } - async function _copyRawPixelsJS(imageIn: Jimp, imageOut: Jimp, srcX: number, srcY: number, dstX: number, dstY: number, inWidth: number, outWidth: number, imgSrcWidth: number, imgDstWidth: number, flip: boolean): Promise { - const inData = imageIn.bitmap.data, - outData = imageIn.bitmap.data; + // async function _copyRawPixelsJS(imageIn: Jimp, imageOut: Jimp, srcX: number, srcY: number, dstX: number, dstY: number, width: number, height: number, imgSrcWidth: number, imgDstWidth: number, flip: boolean): Promise { + // const inData = imageIn.bitmap.data, + // outData = imageOut.bitmap.data; - for (let y = 0; y < outWidth; y++) { - for (let x = 0; x < inWidth; x++) { + // for (let y = 0; y < height; y++) { + // for (let x = 0; x < width; x++) { + // let srcIndex = (srcY + y) * imgSrcWidth + srcX + x; + // let dstIndex = (dstY + y) * imgDstWidth + dstX + x; + + // if (flip) { + // srcIndex = (srcY + y) * imgSrcWidth + srcX + (width - x - 1); + // } + + // for (let c = 0; c < 4; c++) { + // // Assuming RGBA channels + // outData[dstIndex * 4 + c] = inData[srcIndex * 4 + c]; + // } + // } + // } + // return imageOut; + + // // return sharp(outData, { + // // raw: { + // // width: outMeta.width!, + // // height: outMeta.height!, + // // channels: 4, + // // }, + // // }); + // } + + async function _copyRawPixelsJS(imageIn: Jimp, imageOut: Jimp, srcX: number, srcY: number, dstX: number, dstY: number, width: number, height: number, imgSrcWidth: number, imgDstWidth: number, flip: boolean) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { let srcIndex = (srcY + y) * imgSrcWidth + srcX + x; - let dstIndex = (dstY + y) * imgDstWidth + dstX + x; if (flip) { - srcIndex = (srcY + y) * imgSrcWidth + srcX + (inWidth - x - 1); + srcIndex = (srcY + y) * imgSrcWidth + srcX + (width - x - 1); } - for (let c = 0; c < 4; c++) { - // Assuming RGBA channels - outData[dstIndex * 4 + c] = inData[srcIndex * 4 + c]; - } + const pixelColor = imageIn.getPixelColor(srcX + x, srcY + y); + const rgba = Jimp.intToRGBA(pixelColor); + + imageOut.setPixelColor(Jimp.rgbaToInt(rgba.r, rgba.g, rgba.b, rgba.a), dstX + x, dstY + y); } } - return await Jimp.read(outData); - - // return sharp(outData, { - // raw: { - // width: outMeta.width!, - // height: outMeta.height!, - // channels: 4, - // }, - // }); + return imageOut; } export async function copyRawPixels(imageIn: Sharp, imageOut: Sharp, dx1: number, dy1: number, dx2: number, dy2: number, sx1: number, sy1: number, sx2: number, sy2: number): Promise { @@ -111,9 +137,10 @@ export namespace ImageEditor { if (height != 64) { // assume 32 height skin let imageOut = await Jimp.create(64, 64, 0x0); - for (let row = 0; row < height; row++) { - for (let col = 0; col < 64; col++) { - imageOut.setPixelColor(jimpImage.getPixelColor(row, col), row, col); + + for (let x = 0; x < jimpImage.getWidth(); x++) { + for (let y = 0; y < jimpImage.getHeight(); y++) { + imageOut.setPixelColor(jimpImage.getPixelColor(x, y), x, y); } } diff --git a/src/proxy/skins/SimpleRatelimit.ts b/src/proxy/skins/SimpleRatelimit.ts deleted file mode 100644 index 3a41195..0000000 --- a/src/proxy/skins/SimpleRatelimit.ts +++ /dev/null @@ -1,75 +0,0 @@ -export default class SimpleRatelimit { - readonly requestCount: number; - readonly resetInterval: number; - private entries: Map; - - constructor(requestCount: number, resetInterval: number) { - this.requestCount = requestCount; - this.resetInterval = resetInterval; - this.entries = new Map(); - } - - public get(key: T): Ratelimit { - return ( - this.entries.get(key) ?? { - remainingRequests: this.requestCount, - resetTime: new Date(0), - } - ); - } - - public consume(key: T, count?: number): Ratelimit | never { - if (this.entries.has(key)) { - const ratelimit = this.entries.get(key); - if (ratelimit.remainingRequests - (count ?? 1) < 0) { - if (this.requestCount - (count ?? 1) < 0) { - throw new RatelimitExceededError( - `Consume request count is higher than default available request count!` - ); - } else { - throw new RatelimitExceededError( - `Ratelimit exceeded, try again in ${ - ratelimit.resetTime.getDate() - Date.now() - } ms!` - ); - } - } - ratelimit.remainingRequests -= count ?? 1; - return ratelimit; - } else { - if (this.requestCount - (count ?? 1) < 0) { - throw new RatelimitExceededError( - `Consume request count is higher than default available request count!` - ); - } - const ratelimit: Ratelimit = { - remainingRequests: this.requestCount - (count ?? 1), - resetTime: new Date(Date.now() + this.resetInterval), - timer: null, - }; - this.entries.set(key, ratelimit); - ratelimit.timer = this._onAdd(ratelimit); - return ratelimit; - } - } - - private _onAdd(ratelimit: Ratelimit): NodeJS.Timer { - return setInterval(() => { - // TODO: work on - }, this.resetInterval); - } -} - -export type Ratelimit = { - remainingRequests: number; - resetTime: Date; - timer?: NodeJS.Timer; -}; - -export class RatelimitExceededError extends Error { - constructor(message: { toString: () => string }) { - super(message.toString()); - this.name = "RatelimitExceededError"; - Object.setPrototypeOf(this, RatelimitExceededError.prototype); - } -} diff --git a/src/proxy/skins/SkinServer.ts b/src/proxy/skins/SkinServer.ts new file mode 100644 index 0000000..5237f76 --- /dev/null +++ b/src/proxy/skins/SkinServer.ts @@ -0,0 +1,144 @@ +import DiskDB from "../databases/DiskDB.js"; +import crypto from "crypto"; +import { Logger } from "../../logger.js"; +import { Constants } from "../Constants.js"; +import { Enums } from "../Enums.js"; +import { Player } from "../Player.js"; +import { Proxy } from "../Proxy.js"; +import { Util } from "../Util.js"; +import { CSChannelMessagePacket } from "../packets/channel/CSChannelMessage.js"; +import { SCChannelMessagePacket } from "../packets/channel/SCChannelMessage.js"; +import { EaglerSkins } from "./EaglerSkins.js"; +import { ImageEditor } from "./ImageEditor.js"; +import { MineProtocol } from "../Protocol.js"; + +export class SkinServer { + public allowedSkinDomains: string[]; + public cache: DiskDB; + public proxy: Proxy; + public usingNative: boolean; + public usingCache: boolean; + private _logger: Logger; + private deleteTask: NodeJS.Timer; + private lifetime: number; + + constructor(proxy: Proxy, native: boolean, sweepInterval: number, cacheLifetime: number, cacheFolder: string = "./skinCache", useCache: boolean = true, allowedSkinDomains?: string[]) { + this.allowedSkinDomains = allowedSkinDomains ?? ["textures.minecraft.net"]; + if (useCache) { + this.cache = new DiskDB( + cacheFolder, + (v) => exportCachedSkin(v), + (b) => readCachedSkin(b), + (k) => k + ); + } + this.proxy = proxy ?? PROXY; + this.usingCache = useCache; + this.usingNative = native; + this.lifetime = cacheLifetime; + this._logger = new Logger("SkinServer"); + this._logger.info("Started EaglercraftX skin server."); + if (useCache) this.deleteTask = setInterval(async () => await this.cache.filter((ent) => Date.now() < ent.expires), sweepInterval); + } + + public unload() { + if (this.deleteTask != null) clearInterval(this.deleteTask); + } + + public async handleRequest(packet: CSChannelMessagePacket, caller: Player, proxy: Proxy) { + if (packet.messageType == Enums.ChannelMessageType.SERVER) throw new Error("Server message was passed to client message handler!"); + else if (packet.channel != Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME) throw new Error("Cannot handle non-EaglerX skin channel messages!"); + + { + const rl = proxy.ratelimit.skinsConnection.consume(caller.username), + rlip = proxy.ratelimit.skinsIP.consume(caller.ws._socket.remoteAddress); + if (!rl.success || !rlip.success) return; + } + + switch (packet.data[0] as Enums.EaglerSkinPacketId) { + default: + throw new Error("Unknown operation!"); + break; + case Enums.EaglerSkinPacketId.CFetchSkinEaglerPlayerReq: + const parsedPacket_0 = EaglerSkins.readClientFetchEaglerSkinPacket(packet.data); + const player = this.proxy.fetchUserByUUID(parsedPacket_0.uuid); + if (player) { + if (player.skin.type == Enums.SkinType.BUILTIN) { + const response = new SCChannelMessagePacket(); + response.channel = Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME; + response.data = EaglerSkins.writeServerFetchSkinResultBuiltInPacket(player.uuid, player.skin.builtInSkin); + caller.write(response); + } else if (player.skin.type == Enums.SkinType.CUSTOM) { + const response = new SCChannelMessagePacket(); + response.channel = Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME; + response.data = EaglerSkins.writeServerFetchSkinResultCustomPacket(player.uuid, player.skin.skin, false); + caller.write(response); + } else this._logger.warn(`Player ${caller.username} attempted to fetch player ${player.uuid}'s skin, but their skin hasn't loaded yet!`); + } + break; + case Enums.EaglerSkinPacketId.CFetchSkinReq: + const parsedPacket_1 = EaglerSkins.readClientDownloadSkinRequestPacket(packet.data), + url = new URL(parsedPacket_1.url).hostname; + if (!this.allowedSkinDomains.some((domain) => Util.areDomainsEqual(domain, url))) { + this._logger.warn(`Player ${caller.username} tried to download a skin with a disallowed domain name(${url})!`); + break; + } + try { + let cacheHit = null, + skin = null; + if (this.usingCache) { + (cacheHit = await this.cache.get(parsedPacket_1.uuid)), (skin = cacheHit != null ? cacheHit.data : null); + if (!skin) { + this._logger.info("cache miss: getting skin"); + const fetched = await EaglerSkins.downloadSkin(parsedPacket_1.url); + skin = fetched; + await this.cache.set(parsedPacket_1.uuid, { + uuid: parsedPacket_1.uuid, + expires: Date.now() + this.lifetime, + data: fetched, + }); + this._logger.info("downloaded skin, saved to cache"); + } else this._logger.info("hit success: got skin"); + } else { + skin = await EaglerSkins.downloadSkin(parsedPacket_1.url); + } + + const processed = this.usingNative ? await ImageEditor.toEaglerSkin(skin) : await ImageEditor.toEaglerSkinJS(skin), + response = new SCChannelMessagePacket(); + response.channel = Constants.EAGLERCRAFT_SKIN_CHANNEL_NAME; + response.data = EaglerSkins.writeServerFetchSkinResultCustomPacket(parsedPacket_1.uuid, processed, true); + caller.write(response); + } catch (err) { + this._logger.warn(`Failed to fetch skin URL ${parsedPacket_1.url} for player ${caller.username}: ${err.stack ?? err}`); + } + } + } +} + +export type CachedSkin = { + uuid: string; + expires: number; + data: Buffer; +}; + +function digestMd5Hex(data: Buffer | string): string { + return crypto.createHash("md5").update(data).digest("hex"); +} + +function exportCachedSkin(skin: CachedSkin): Buffer { + const endUuid = MineProtocol.writeString(skin.uuid), + encExp = MineProtocol.writeVarLong(skin.expires), + encSkin = MineProtocol.writeBinary(skin.data); + return Buffer.concat([endUuid, encExp, encSkin]); +} + +function readCachedSkin(data: Buffer): CachedSkin { + const readUuid = MineProtocol.readString(data), + readExp = MineProtocol.readVarLong(readUuid.newBuffer), + readSkin = MineProtocol.readBinary(readExp.newBuffer); + return { + uuid: readUuid.value, + expires: readExp.value, + data: readSkin.value, + }; +}