mirror of
https://github.com/WorldEditAxe/eaglerproxy.git
synced 2024-11-21 04:56:04 -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";
|
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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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}.`);
|
||||||
|
|
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 {
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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