mirror of
https://github.com/WorldEditAxe/eaglerproxy.git
synced 2024-12-21 14:54:13 -08:00
Add skin caching & fix pure JS skin server
This commit is contained in:
parent
36adb113c0
commit
4b465802d1
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<number> {
|
||||
export function readVarInt(buff: Buffer, offset?: number): ReadResult<number> {
|
||||
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<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 {
|
||||
const bufferized = Buffer.from(str, "utf8"),
|
||||
len = writeVarInt(bufferized.length);
|
||||
return Buffer.concat([len, bufferized]);
|
||||
}
|
||||
|
||||
export function readString(
|
||||
buff: Buffer,
|
||||
offset?: number
|
||||
): ReadResult<string> {
|
||||
export function readString(buff: Buffer, offset?: number): ReadResult<string> {
|
||||
buff = offset ? buff.subarray(offset) : buff;
|
||||
const len = readVarInt(buff),
|
||||
str = len.newBuffer.subarray(0, len.value).toString("utf8");
|
||||
|
|
|
@ -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}.`);
|
||||
|
|
52
src/proxy/databases/DiskDB.ts
Normal file
52
src/proxy/databases/DiskDB.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -1,27 +1,35 @@
|
|||
import { Logger } from "../../logger.js";
|
||||
|
||||
export default class BucketRateLimiter {
|
||||
public capacity: number;
|
||||
public refillsPerMin: number;
|
||||
public keyMap: Map<string, KeyData>;
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<Jimp> {
|
||||
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<Jimp> {
|
||||
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<Jimp> {
|
||||
// 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<Sharp> {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
144
src/proxy/skins/SkinServer.ts
Normal file
144
src/proxy/skins/SkinServer.ts
Normal 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,
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user