Add skin caching & fix pure JS skin server

This commit is contained in:
q13x 2024-03-21 00:02:55 -07:00
parent 36adb113c0
commit 4b465802d1
11 changed files with 334 additions and 199 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -4,10 +4,6 @@
import { Config } from "./launcher_types.js"; import { Config } from "./launcher_types.js";
export const config: Config = { export const config: Config = {
bridge: {
enabled: false,
motd: null,
},
adapter: { adapter: {
name: "EaglerProxy", name: "EaglerProxy",
bindHost: "0.0.0.0", bindHost: "0.0.0.0",
@ -16,6 +12,12 @@ export const config: Config = {
useNatives: false, useNatives: false,
skinServer: { skinServer: {
skinUrlWhitelist: undefined, skinUrlWhitelist: undefined,
cache: {
useCache: true,
folderName: "skinCache",
skinCacheLifetime: 60 * 1000,
skinCachePruneInterval: 5000,
},
}, },
motd: true motd: true
? "FORWARD" ? "FORWARD"
@ -30,7 +32,7 @@ export const config: Config = {
http: 100, http: 100,
ws: 100, ws: 100,
motd: 100, motd: 100,
skins: 1000, skins: 100, // adjust as necessary
skinsIp: 10000, skinsIp: 10000,
connect: 100, connect: 100,
}, },
@ -41,8 +43,8 @@ export const config: Config = {
originBlacklist: null, originBlacklist: null,
}, },
server: { server: {
host: "0.0.0.0", host: "127.0.0.1",
port: 25565, port: 1111,
}, },
tls: undefined, tls: undefined,
}, },

View File

@ -1,5 +1,4 @@
export type Config = { export type Config = {
bridge: BridgeOptions;
adapter: AdapterOptions; adapter: AdapterOptions;
}; };
@ -22,6 +21,12 @@ export type AdapterOptions = {
useNatives?: boolean; useNatives?: boolean;
skinServer: { skinServer: {
skinUrlWhitelist?: string[]; skinUrlWhitelist?: string[];
cache: {
useCache: boolean;
folderName?: string;
skinCacheLifetime?: number;
skinCachePruneInterval?: number;
};
}; };
origins: { origins: {
allowOfflineDownloads: boolean; allowOfflineDownloads: boolean;

View File

@ -1,7 +1,4 @@
import { import { encodeULEB128 as _encodeVarInt, decodeULEB128 as _decodeVarInt } from "@thi.ng/leb128";
encodeULEB128 as _encodeVarInt,
decodeULEB128 as _decodeVarInt,
} from "@thi.ng/leb128";
import { Enums } from "./Enums.js"; import { Enums } from "./Enums.js";
import { Util } from "./Util.js"; import { Util } from "./Util.js";
@ -24,10 +21,7 @@ export namespace MineProtocol {
return Buffer.from(_encodeVarInt(int)); return Buffer.from(_encodeVarInt(int));
} }
export function readVarInt( export function readVarInt(buff: Buffer, offset?: number): ReadResult<number> {
buff: Buffer,
offset?: number
): ReadResult<number> {
buff = offset ? buff.subarray(offset) : buff; buff = offset ? buff.subarray(offset) : buff;
const read = _decodeVarInt(buff), const read = _decodeVarInt(buff),
len = read[1]; 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<number> {
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<Buffer> {
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 { export function writeString(str: string): Buffer {
const bufferized = Buffer.from(str, "utf8"), const bufferized = Buffer.from(str, "utf8"),
len = writeVarInt(bufferized.length); len = writeVarInt(bufferized.length);
return Buffer.concat([len, bufferized]); return Buffer.concat([len, bufferized]);
} }
export function readString( export function readString(buff: Buffer, offset?: number): ReadResult<string> {
buff: Buffer,
offset?: number
): ReadResult<string> {
buff = offset ? buff.subarray(offset) : buff; buff = offset ? buff.subarray(offset) : buff;
const len = readVarInt(buff), const len = readVarInt(buff),
str = len.newBuffer.subarray(0, len.value).toString("utf8"); str = len.newBuffer.subarray(0, len.value).toString("utf8");

View File

@ -26,6 +26,8 @@ import { CSChannelMessagePacket } from "./packets/channel/CSChannelMessage.js";
import { Constants, UPGRADE_REQUIRED_RESPONSE } from "./Constants.js"; import { Constants, UPGRADE_REQUIRED_RESPONSE } from "./Constants.js";
import { PluginManager } from "./pluginLoader/PluginManager.js"; import { PluginManager } from "./pluginLoader/PluginManager.js";
import ProxyRatelimitManager from "./ratelimit/ProxyRatelimitManager.js"; import ProxyRatelimitManager from "./ratelimit/ProxyRatelimitManager.js";
import { ChatColor } from "../plugins/EagProxyAAS/types.js";
import { SkinServer } from "./skins/SkinServer.js";
let instanceCount = 0; let instanceCount = 0;
const chalk = new Chalk({ level: 2 }); const chalk = new Chalk({ level: 2 });
@ -42,7 +44,7 @@ export class Proxy extends EventEmitter {
public config: Config["adapter"]; public config: Config["adapter"];
public wsServer: WebSocketServer; public wsServer: WebSocketServer;
public httpServer: http.Server; public httpServer: http.Server;
public skinServer: EaglerSkins.SkinServer; public skinServer: SkinServer;
public broadcastMotd?: Motd.MOTD; public broadcastMotd?: Motd.MOTD;
public ratelimit: ProxyRatelimitManager; 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!"); if (this.loaded) throw new Error("Can't initiate if proxy instance is already initialized or is being initialized!");
this.loaded = true; this.loaded = true;
this.packetRegistry = await loadPackets(); 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; global.PACKET_REGISTRY = this.packetRegistry;
if (this.config.motd == "FORWARD") { if (this.config.motd == "FORWARD") {
this._pollServer(this.config.server.host, this.config.server.port); 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}`); 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.ratelimit = new ProxyRatelimitManager(this.config.ratelimits);
this.pluginManager.emit("proxyFinishLoading", this, this.pluginManager); this.pluginManager.emit("proxyFinishLoading", this, this.pluginManager);
this._logger.info(`Started WebSocket server and binded to ${this.config.bindHost} on port ${this.config.bindPort}.`); this._logger.info(`Started WebSocket server and binded to ${this.config.bindHost} on port ${this.config.bindPort}.`);

View File

@ -0,0 +1,52 @@
import path from "path";
import fs from "fs/promises";
import fss from "fs";
export default class DiskDB<T extends any> {
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<T | null> {
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);
}
}

View File

@ -1,27 +1,35 @@
import { Logger } from "../../logger.js";
export default class BucketRateLimiter { export default class BucketRateLimiter {
public capacity: number; public capacity: number;
public refillsPerMin: number; public refillsPerMin: number;
public keyMap: Map<string, KeyData>; public keyMap: Map<string, KeyData>;
public static readonly GC_TOLERANCE: number = 50; public static readonly GC_TOLERANCE: number = 50;
private sweeper: NodeJS.Timer;
constructor(capacity: number, refillsPerMin: number) { constructor(capacity: number, refillsPerMin: number) {
this.capacity = capacity; this.capacity = capacity;
this.refillsPerMin = refillsPerMin; this.refillsPerMin = refillsPerMin;
this.keyMap = new Map(); this.keyMap = new Map();
this.sweeper = setInterval(() => {
this.removeFull();
}, 5000);
}
public cleanUp() {
clearInterval(this.sweeper);
} }
public consume(key: string, consumeTokens: number = 1): RateLimitData { public consume(key: string, consumeTokens: number = 1): RateLimitData {
if (this.keyMap.size > BucketRateLimiter.GC_TOLERANCE) this.removeFull();
if (this.keyMap.has(key)) { if (this.keyMap.has(key)) {
const bucket = this.keyMap.get(key); const bucket = this.keyMap.get(key);
// refill bucket as needed
const now = Date.now(); 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); 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); bucket.lastRefillTime = now - (refillTimes % 60000);
} } else if (now - bucket.lastRefillTime > 60000 && bucket.tokens >= this.capacity) bucket.lastRefillTime = now;
if (bucket.tokens >= consumeTokens) { if (bucket.tokens >= consumeTokens) {
bucket.tokens -= consumeTokens; bucket.tokens -= consumeTokens;
@ -93,7 +101,13 @@ export default class BucketRateLimiter {
public removeFull() { public removeFull() {
let remove: string[] = []; let remove: string[] = [];
const now = Date.now();
this.keyMap.forEach((v, k) => { 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) { if (v.tokens == this.capacity) {
remove.push(k); remove.push(k);
} }

View File

@ -144,6 +144,11 @@ export namespace EaglerSkins {
return this; 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 { export function writeServerFetchSkinResultCustomPacket(uuid: string | Buffer, skin: Buffer, downloaded: boolean): Buffer {
uuid = typeof uuid == "string" ? Util.uuidStringToBuffer(uuid) : uuid; uuid = typeof uuid == "string" ? Util.uuidStringToBuffer(uuid) : uuid;
return Buffer.concat( return Buffer.concat(
@ -168,72 +173,6 @@ export namespace EaglerSkins {
return ret; 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 { export class EaglerSkin {
owner: Player; owner: Player;
type: Enums.SkinType; type: Enums.SkinType;

View File

@ -2,10 +2,11 @@ import { Constants } from "../Constants.js";
import { Enums } from "../Enums.js"; import { Enums } from "../Enums.js";
import { MineProtocol } from "../Protocol.js"; import { MineProtocol } from "../Protocol.js";
import { Util } from "../Util.js"; import { Util } from "../Util.js";
import Jimp from "jimp";
import fs from "fs/promises"; import fs from "fs/promises";
let Jimp: Jimp = null; // let Jimp: Jimp = null;
type Jimp = any; // type Jimp = any;
let sharp: any = null; let sharp: any = null;
type Sharp = any; type Sharp = any;
@ -16,16 +17,23 @@ export namespace ImageEditor {
export async function loadLibraries(native: boolean) { export async function loadLibraries(native: boolean) {
if (loadedLibraries) return; if (loadedLibraries) return;
if (native) sharp = (await import("sharp")).default; 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; 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<Jimp> { 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<Jimp> {
console.log(imageOut);
if (dx1 > dx2) { if (dx1 > dx2) {
return _copyRawPixelsJS(imageIn, imageOut, sx1, sy1, dx2, dy1, sx2 - sx1, sy2 - sy1, imageIn.getWidth(), imageOut.getWidth(), true); return _copyRawPixelsJS(imageIn, imageOut, sx1, sy1, dx2, dy1, sx2 - sx1, sy2 - sy1, imageIn.getWidth(), imageOut.getWidth(), true);
} else { } 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<Jimp> { // 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<Jimp> {
const inData = imageIn.bitmap.data, // const inData = imageIn.bitmap.data,
outData = imageIn.bitmap.data; // outData = imageOut.bitmap.data;
for (let y = 0; y < outWidth; y++) { // for (let y = 0; y < height; y++) {
for (let x = 0; x < inWidth; x++) { // 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 srcIndex = (srcY + y) * imgSrcWidth + srcX + x;
let dstIndex = (dstY + y) * imgDstWidth + dstX + x;
if (flip) { 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++) { const pixelColor = imageIn.getPixelColor(srcX + x, srcY + y);
// Assuming RGBA channels const rgba = Jimp.intToRGBA(pixelColor);
outData[dstIndex * 4 + c] = inData[srcIndex * 4 + c];
} imageOut.setPixelColor(Jimp.rgbaToInt(rgba.r, rgba.g, rgba.b, rgba.a), dstX + x, dstY + y);
} }
} }
return await Jimp.read(outData); return imageOut;
// return sharp(outData, {
// raw: {
// width: outMeta.width!,
// height: outMeta.height!,
// channels: 4,
// },
// });
} }
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<Sharp> { 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<Sharp> {
@ -111,9 +137,10 @@ export namespace ImageEditor {
if (height != 64) { if (height != 64) {
// assume 32 height skin // assume 32 height skin
let imageOut = await Jimp.create(64, 64, 0x0); let imageOut = await Jimp.create(64, 64, 0x0);
for (let row = 0; row < height; row++) {
for (let col = 0; col < 64; col++) { for (let x = 0; x < jimpImage.getWidth(); x++) {
imageOut.setPixelColor(jimpImage.getPixelColor(row, col), row, col); for (let y = 0; y < jimpImage.getHeight(); y++) {
imageOut.setPixelColor(jimpImage.getPixelColor(x, y), x, y);
} }
} }

View File

@ -1,75 +0,0 @@
export default class SimpleRatelimit<T> {
readonly requestCount: number;
readonly resetInterval: number;
private entries: Map<T, Ratelimit>;
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);
}
}

View File

@ -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<CachedSkin>;
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,
};
}