minecraft-toolkit 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -1
- package/index.d.ts +251 -0
- package/index.js +2 -0
- package/package.json +12 -8
- package/src/constants.js +1 -0
- package/src/h3/routes.js +49 -13
- package/src/server/icon.js +28 -0
- package/src/server/votifier/index.js +328 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
<!-- /automd -->
|
|
10
10
|
|
|
11
|
-
Lightweight
|
|
11
|
+
Lightweight Minecraft API + infrastructure toolkit: player profiles & textures, Java/Bedrock server status probes, and Votifier (v1/v2) clients that run in Node, Vite, and edge runtimes.
|
|
12
12
|
|
|
13
13
|
> This toolkit wraps Mojang APIs. Rate limits and availability still apply. Write endpoints (name change, skin upload) are not yet included.
|
|
14
14
|
|
|
@@ -151,6 +151,48 @@ console.log(javaStatus.players.online, bedrockStatus.motd.text);
|
|
|
151
151
|
Both helpers normalize MOTD text, favicon/Base64 icons, latency, and version info. Errors surface as
|
|
152
152
|
`MinecraftToolkitError` with contextual status codes.
|
|
153
153
|
|
|
154
|
+
### Server Icon Helper
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { fetchServerIcon } from "minecraft-toolkit";
|
|
158
|
+
|
|
159
|
+
const icon = await fetchServerIcon("play.example.net");
|
|
160
|
+
console.log(icon.base64); // "iVBOR..."
|
|
161
|
+
console.log(icon.byteLength); // raw PNG size in bytes
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The helper reuses the Java status ping to extract the favicon, returning:
|
|
165
|
+
|
|
166
|
+
- `dataUri`: ready-to-render `data:image/png;base64,...`
|
|
167
|
+
- `base64`: raw Base64 payload
|
|
168
|
+
- `buffer` + `byteLength` for further processing (e.g., resizing, hashing)
|
|
169
|
+
|
|
170
|
+
If the server doesn’t expose an icon, it throws `MinecraftToolkitError` (404).
|
|
171
|
+
|
|
172
|
+
## Votifier Client (Java)
|
|
173
|
+
|
|
174
|
+
Send vote notifications to classic Votifier v1 (RSA public key) and NuVotifier v2 (token/HMAC) servers without re-implementing either protocol.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { sendVotifierVote } from "minecraft-toolkit";
|
|
178
|
+
|
|
179
|
+
const result = await sendVotifierVote({
|
|
180
|
+
host: "votifier.myserver.net",
|
|
181
|
+
port: 8192, // defaults to 8192 if omitted
|
|
182
|
+
publicKey: process.env.VOTIFIER_PUBLIC_KEY!, // v1 servers
|
|
183
|
+
serviceName: "MyTopList",
|
|
184
|
+
username: "26bz",
|
|
185
|
+
address: "198.51.100.42",
|
|
186
|
+
token: listingSiteConfig.token, // v2 servers (optional)
|
|
187
|
+
protocol: "auto", // let the handshake decide between v1/v2
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
console.log(result.acknowledged, result.version, result.protocol);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- Provide either a legacy RSA public key (for protocol v1) **or** a NuVotifier token (protocol v2). Server listing sites typically store each server's token and pass it here; `protocol: "auto"` will select the right flow based on the handshake.
|
|
194
|
+
- `timestamp` accepts a `Date` or millisecond value (default: `Date.now()`). All failures bubble as `MinecraftToolkitError`.
|
|
195
|
+
|
|
154
196
|
## Minecraft Formatting Renderer
|
|
155
197
|
|
|
156
198
|
Convert legacy `§` or `&` codes into safe HTML fragments or CSS class spans.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { H3 } from "h3";
|
|
2
|
+
import type { Buffer } from "node:buffer";
|
|
3
|
+
|
|
4
|
+
export interface SkinTexture {
|
|
5
|
+
url: string;
|
|
6
|
+
metadata?: {
|
|
7
|
+
model?: "default" | "slim";
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CapeTexture {
|
|
12
|
+
url: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PlayerProfile {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
profile: Record<string, unknown>;
|
|
19
|
+
textures: Record<string, unknown>;
|
|
20
|
+
skin: SkinTexture | null;
|
|
21
|
+
cape: CapeTexture | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PlayerSkin {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
skin: SkinTexture | null;
|
|
28
|
+
cape: CapeTexture | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PlayerSummary {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
skinUrl: string | null;
|
|
35
|
+
capeUrl: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BatchResult {
|
|
39
|
+
username: string;
|
|
40
|
+
profile?: PlayerProfile;
|
|
41
|
+
error?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BatchOptions {
|
|
45
|
+
delayMs?: number;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SkinMetadataResult {
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
skin: SkinTexture | null;
|
|
53
|
+
cape: CapeTexture | null;
|
|
54
|
+
hasCape: boolean;
|
|
55
|
+
dominantColor: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function fetchPlayerProfile(username: string): Promise<PlayerProfile>;
|
|
59
|
+
export function fetchPlayerSkin(username: string): Promise<PlayerSkin>;
|
|
60
|
+
export function fetchPlayerUUID(username: string): Promise<{ id: string; name: string }>;
|
|
61
|
+
export function fetchUsernameByUUID(uuid: string): Promise<{ id: string; name: string }>;
|
|
62
|
+
export function fetchNameHistory(uuid: string): Promise<{ name: string; changedAt: Date | null }[]>;
|
|
63
|
+
export function fetchPlayers(usernames: string[], options?: BatchOptions): Promise<BatchResult[]>;
|
|
64
|
+
export function fetchPlayerSummary(username: string): Promise<PlayerSummary>;
|
|
65
|
+
export function playerExists(username: string): Promise<boolean>;
|
|
66
|
+
export function hasSkinChanged(profileA: PlayerProfile, profileB: PlayerProfile): boolean;
|
|
67
|
+
export function fetchSkinMetadata(
|
|
68
|
+
username: string,
|
|
69
|
+
options?: {
|
|
70
|
+
dominantColor?: boolean;
|
|
71
|
+
sampleRegion?: { x?: number; y?: number; width?: number; height?: number };
|
|
72
|
+
},
|
|
73
|
+
): Promise<SkinMetadataResult>;
|
|
74
|
+
export function fetchSkinDominantColor(
|
|
75
|
+
url: string,
|
|
76
|
+
region?: { x?: number; y?: number; width?: number; height?: number },
|
|
77
|
+
): Promise<string | null>;
|
|
78
|
+
export function resolvePlayer(input: string): Promise<PlayerSkin>;
|
|
79
|
+
|
|
80
|
+
export function isValidUsername(username: string): boolean;
|
|
81
|
+
export function isUUID(value: string): boolean;
|
|
82
|
+
export function normalizeUUID(uuid: string): string;
|
|
83
|
+
export function uuidWithDashes(uuid: string): string;
|
|
84
|
+
export function uuidWithoutDashes(uuid: string): string;
|
|
85
|
+
|
|
86
|
+
export type FormattingMode = "inline" | "class";
|
|
87
|
+
|
|
88
|
+
export interface FormattingOptions {
|
|
89
|
+
mode?: FormattingMode;
|
|
90
|
+
classPrefix?: string;
|
|
91
|
+
animationName?: string;
|
|
92
|
+
obfuscatedSpeedMs?: number;
|
|
93
|
+
escapeHtml?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface FormattingColorMeta {
|
|
97
|
+
name: string;
|
|
98
|
+
classSuffix: string;
|
|
99
|
+
hex: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface FormattingFormatMeta {
|
|
103
|
+
name: string;
|
|
104
|
+
classSuffix: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface FormattingMaps {
|
|
108
|
+
colors: Record<string, FormattingColorMeta>;
|
|
109
|
+
formats: Record<string, FormattingFormatMeta>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function toHTML(input: string, options?: FormattingOptions): string;
|
|
113
|
+
export function stripCodes(input: string): string;
|
|
114
|
+
export function generateCSS(options?: FormattingOptions): string;
|
|
115
|
+
export function hasCodes(input: string): boolean;
|
|
116
|
+
export function convertPrefix(input: string, direction?: "toSection" | "toAmpersand"): string;
|
|
117
|
+
export function getMaps(): FormattingMaps;
|
|
118
|
+
|
|
119
|
+
export function getSkinURL(profile: PlayerProfile | PlayerSkin): string | null;
|
|
120
|
+
export function getCapeURL(profile: PlayerProfile | PlayerSkin): string | null;
|
|
121
|
+
export function getSkinModel(profile: PlayerProfile | PlayerSkin): "default" | "slim";
|
|
122
|
+
export function extractTextureHash(url: string | null): string | null;
|
|
123
|
+
|
|
124
|
+
export type ServerEdition = "java" | "bedrock" | "auto";
|
|
125
|
+
|
|
126
|
+
export interface JavaServerStatusOptions {
|
|
127
|
+
port?: number;
|
|
128
|
+
timeoutMs?: number;
|
|
129
|
+
protocolVersion?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface BedrockServerStatusOptions {
|
|
133
|
+
port?: number;
|
|
134
|
+
timeoutMs?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ServerStatusOptions extends JavaServerStatusOptions {
|
|
138
|
+
edition?: ServerEdition;
|
|
139
|
+
type?: ServerEdition;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface JavaServerStatus {
|
|
143
|
+
edition: "java";
|
|
144
|
+
online: boolean;
|
|
145
|
+
host: string;
|
|
146
|
+
port: number;
|
|
147
|
+
version: {
|
|
148
|
+
name?: string | null;
|
|
149
|
+
protocol?: number | null;
|
|
150
|
+
} | null;
|
|
151
|
+
players: {
|
|
152
|
+
max?: number | null;
|
|
153
|
+
online?: number | null;
|
|
154
|
+
sample?: Array<{ name: string; id: string }>;
|
|
155
|
+
} | null;
|
|
156
|
+
motd: string | null;
|
|
157
|
+
favicon: string | null;
|
|
158
|
+
latencyMs: number | null;
|
|
159
|
+
raw: Record<string, unknown>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface BedrockServerStatus {
|
|
163
|
+
edition: "bedrock";
|
|
164
|
+
online: boolean;
|
|
165
|
+
host: string;
|
|
166
|
+
port: number;
|
|
167
|
+
motd: string;
|
|
168
|
+
version: {
|
|
169
|
+
protocol: number;
|
|
170
|
+
name: string;
|
|
171
|
+
};
|
|
172
|
+
players: {
|
|
173
|
+
online: number;
|
|
174
|
+
max: number;
|
|
175
|
+
};
|
|
176
|
+
serverId: string;
|
|
177
|
+
map: string;
|
|
178
|
+
gamemode: string;
|
|
179
|
+
ipv4Port: number;
|
|
180
|
+
ipv6Port: number | null;
|
|
181
|
+
raw: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type ServerStatus = JavaServerStatus | BedrockServerStatus;
|
|
185
|
+
|
|
186
|
+
export interface ServerIconResult {
|
|
187
|
+
host: string;
|
|
188
|
+
port: number;
|
|
189
|
+
dataUri: string;
|
|
190
|
+
base64: string;
|
|
191
|
+
buffer: Buffer;
|
|
192
|
+
byteLength: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function fetchServerStatus(
|
|
196
|
+
address: string,
|
|
197
|
+
options?: ServerStatusOptions,
|
|
198
|
+
): Promise<ServerStatus>;
|
|
199
|
+
export function fetchJavaServerStatus(
|
|
200
|
+
address: string,
|
|
201
|
+
options?: JavaServerStatusOptions,
|
|
202
|
+
): Promise<JavaServerStatus>;
|
|
203
|
+
export function fetchBedrockServerStatus(
|
|
204
|
+
address: string,
|
|
205
|
+
options?: BedrockServerStatusOptions,
|
|
206
|
+
): Promise<BedrockServerStatus>;
|
|
207
|
+
export function fetchServerIcon(
|
|
208
|
+
address: string,
|
|
209
|
+
options?: JavaServerStatusOptions,
|
|
210
|
+
): Promise<ServerIconResult>;
|
|
211
|
+
|
|
212
|
+
export interface VotifierVoteOptions {
|
|
213
|
+
host: string;
|
|
214
|
+
port?: number;
|
|
215
|
+
publicKey: string;
|
|
216
|
+
serviceName: string;
|
|
217
|
+
username: string;
|
|
218
|
+
address: string;
|
|
219
|
+
timestamp?: number | Date;
|
|
220
|
+
timeoutMs?: number;
|
|
221
|
+
token?: string;
|
|
222
|
+
protocol?: "auto" | "v1" | "v2";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface VotifierVoteResult {
|
|
226
|
+
acknowledged: boolean;
|
|
227
|
+
version: string | null;
|
|
228
|
+
protocol: "v1" | "v2";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function sendVotifierVote(options: VotifierVoteOptions): Promise<VotifierVoteResult>;
|
|
232
|
+
|
|
233
|
+
export interface PlayerHandlers {
|
|
234
|
+
profileHandler: any;
|
|
235
|
+
skinHandler: any;
|
|
236
|
+
summaryHandler: any;
|
|
237
|
+
uuidHandler: any;
|
|
238
|
+
resolverHandler: any;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function createPlayerHandlers(): PlayerHandlers;
|
|
242
|
+
export function createPlayerApp(options?: { app?: ConstructorParameters<typeof H3>[0] }): {
|
|
243
|
+
app: H3;
|
|
244
|
+
handlers: PlayerHandlers;
|
|
245
|
+
};
|
|
246
|
+
export const playerPlugin: (app: H3) => PlayerHandlers;
|
|
247
|
+
|
|
248
|
+
export function fetchNameChangeInfo(accessToken: string): Promise<any>;
|
|
249
|
+
export function checkNameAvailability(name: string, accessToken: string): Promise<any>;
|
|
250
|
+
export function validateGiftCode(code: string, accessToken: string): Promise<boolean>;
|
|
251
|
+
export function fetchBlockedServers(): Promise<string[]>;
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minecraft-toolkit",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Minecraft toolkit for Mojang player data, server status probes, and Votifier v1/v2 clients.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"minecraft",
|
|
7
7
|
"minecraft-api",
|
|
8
8
|
"minecraft-player",
|
|
9
|
+
"minecraft-server",
|
|
9
10
|
"minecraft-skins",
|
|
11
|
+
"minecraft-status",
|
|
10
12
|
"minecraft-tools",
|
|
11
13
|
"minecraft-utils",
|
|
12
14
|
"minecraft-uuid",
|
|
15
|
+
"minecraft-votifier",
|
|
13
16
|
"mojang",
|
|
14
|
-
"mojang-api"
|
|
17
|
+
"mojang-api",
|
|
18
|
+
"votifier"
|
|
15
19
|
],
|
|
16
20
|
"homepage": "https://github.com/26bz/minecraft-toolkit",
|
|
17
21
|
"bugs": {
|
|
@@ -30,6 +34,7 @@
|
|
|
30
34
|
"files": [
|
|
31
35
|
"src",
|
|
32
36
|
"index.js",
|
|
37
|
+
"index.d.ts",
|
|
33
38
|
"README.md",
|
|
34
39
|
"LICENSE"
|
|
35
40
|
],
|
|
@@ -37,11 +42,10 @@
|
|
|
37
42
|
"sideEffects": false,
|
|
38
43
|
"types": "./index.d.ts",
|
|
39
44
|
"exports": {
|
|
40
|
-
".":
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"./textures": "./src/player/textures.js"
|
|
45
|
+
".": {
|
|
46
|
+
"types": "./index.d.ts",
|
|
47
|
+
"default": "./index.js"
|
|
48
|
+
}
|
|
45
49
|
},
|
|
46
50
|
"scripts": {
|
|
47
51
|
"test": "vitest",
|
package/src/constants.js
CHANGED
|
@@ -11,6 +11,7 @@ export const PACKAGE_METADATA = {
|
|
|
11
11
|
|
|
12
12
|
export const DEFAULT_JAVA_PORT = 25565;
|
|
13
13
|
export const DEFAULT_BEDROCK_PORT = 19132;
|
|
14
|
+
export const DEFAULT_VOTIFIER_PORT = 8192;
|
|
14
15
|
export const DEFAULT_CACHE_TTL_SECONDS = 30;
|
|
15
16
|
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
16
17
|
export const DEFAULT_PROTOCOL_VERSION = 760; // Minecraft 1.20.4
|
package/src/h3/routes.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
H3,
|
|
3
|
+
defineHandler,
|
|
4
|
+
definePlugin,
|
|
5
|
+
readBody,
|
|
6
|
+
getQuery,
|
|
7
|
+
getRouterParam,
|
|
8
|
+
getRequestHeader,
|
|
9
|
+
} from "h3";
|
|
2
10
|
import { MinecraftToolkitError } from "../errors.js";
|
|
3
11
|
import {
|
|
4
12
|
fetchPlayerProfile,
|
|
@@ -17,10 +25,11 @@ import {
|
|
|
17
25
|
fetchBlockedServers,
|
|
18
26
|
} from "../player/account/index.js";
|
|
19
27
|
import { fetchServerStatus } from "../server/status.js";
|
|
28
|
+
import { fetchServerIcon } from "../server/icon.js";
|
|
20
29
|
import { normalizeAddress, validatePort } from "../utils/validation.js";
|
|
21
30
|
|
|
22
|
-
function
|
|
23
|
-
const value = event
|
|
31
|
+
function requireRouterParam(event, key, message) {
|
|
32
|
+
const value = getRouterParam(event, key);
|
|
24
33
|
if (!value) {
|
|
25
34
|
throw new MinecraftToolkitError(message, { statusCode: 400 });
|
|
26
35
|
}
|
|
@@ -29,37 +38,37 @@ function requireParam(event, key, message) {
|
|
|
29
38
|
|
|
30
39
|
export function createPlayerHandlers() {
|
|
31
40
|
const profileHandler = defineHandler(async (event) => {
|
|
32
|
-
const username =
|
|
41
|
+
const username = requireRouterParam(event, "username", "Username parameter is required");
|
|
33
42
|
return fetchPlayerProfile(username);
|
|
34
43
|
});
|
|
35
44
|
|
|
36
45
|
const skinHandler = defineHandler(async (event) => {
|
|
37
|
-
const username =
|
|
46
|
+
const username = requireRouterParam(event, "username", "Username parameter is required");
|
|
38
47
|
return fetchPlayerSkin(username);
|
|
39
48
|
});
|
|
40
49
|
|
|
41
50
|
const summaryHandler = defineHandler(async (event) => {
|
|
42
|
-
const username =
|
|
51
|
+
const username = requireRouterParam(event, "username", "Username parameter is required");
|
|
43
52
|
return fetchPlayerSummary(username);
|
|
44
53
|
});
|
|
45
54
|
|
|
46
55
|
const uuidHandler = defineHandler(async (event) => {
|
|
47
|
-
const username =
|
|
56
|
+
const username = requireRouterParam(event, "username", "Username parameter is required");
|
|
48
57
|
return fetchPlayerUUID(username);
|
|
49
58
|
});
|
|
50
59
|
|
|
51
60
|
const resolverHandler = defineHandler(async (event) => {
|
|
52
|
-
const input =
|
|
61
|
+
const input = requireRouterParam(event, "input", "Username or UUID parameter is required");
|
|
53
62
|
return resolvePlayer(input);
|
|
54
63
|
});
|
|
55
64
|
|
|
56
65
|
const nameHistoryHandler = defineHandler(async (event) => {
|
|
57
|
-
const uuid =
|
|
66
|
+
const uuid = requireRouterParam(event, "uuid", "UUID parameter is required");
|
|
58
67
|
return fetchNameHistory(uuid);
|
|
59
68
|
});
|
|
60
69
|
|
|
61
70
|
const existsHandler = defineHandler(async (event) => {
|
|
62
|
-
const username =
|
|
71
|
+
const username = requireRouterParam(event, "username", "Username parameter is required");
|
|
63
72
|
const exists = await playerExists(username);
|
|
64
73
|
return { username, exists };
|
|
65
74
|
});
|
|
@@ -82,7 +91,7 @@ export function createPlayerHandlers() {
|
|
|
82
91
|
});
|
|
83
92
|
|
|
84
93
|
const nameAvailabilityHandler = defineHandler(async (event) => {
|
|
85
|
-
const name =
|
|
94
|
+
const name = requireRouterParam(event, "name", "Name parameter is required");
|
|
86
95
|
const token = requireAccessToken(event);
|
|
87
96
|
return checkNameAvailability(name, token);
|
|
88
97
|
});
|
|
@@ -100,9 +109,31 @@ export function createPlayerHandlers() {
|
|
|
100
109
|
|
|
101
110
|
const blockedServersHandler = defineHandler(async () => fetchBlockedServers());
|
|
102
111
|
|
|
112
|
+
const serverIconHandler = defineHandler(async (event) => {
|
|
113
|
+
const address = normalizeAddress(
|
|
114
|
+
requireRouterParam(event, "address", "Server address parameter is required"),
|
|
115
|
+
);
|
|
116
|
+
const query = getQuery(event);
|
|
117
|
+
const port = typeof query.port === "string" ? validatePort(query.port) : undefined;
|
|
118
|
+
const timeoutMs =
|
|
119
|
+
typeof query.timeoutMs === "string" ? Number.parseInt(query.timeoutMs, 10) : undefined;
|
|
120
|
+
const protocolVersion =
|
|
121
|
+
typeof query.protocolVersion === "string"
|
|
122
|
+
? Number.parseInt(query.protocolVersion, 10)
|
|
123
|
+
: undefined;
|
|
124
|
+
|
|
125
|
+
const icon = await fetchServerIcon(address, {
|
|
126
|
+
port,
|
|
127
|
+
timeoutMs,
|
|
128
|
+
protocolVersion,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return icon.buffer;
|
|
132
|
+
});
|
|
133
|
+
|
|
103
134
|
const serverStatusHandler = defineHandler(async (event) => {
|
|
104
135
|
const address = normalizeAddress(
|
|
105
|
-
|
|
136
|
+
requireRouterParam(event, "address", "Server address parameter is required"),
|
|
106
137
|
);
|
|
107
138
|
const query = getQuery(event);
|
|
108
139
|
const edition = typeof query.edition === "string" ? query.edition : undefined;
|
|
@@ -136,6 +167,7 @@ export function createPlayerHandlers() {
|
|
|
136
167
|
giftCodeValidationHandler,
|
|
137
168
|
blockedServersHandler,
|
|
138
169
|
serverStatusHandler,
|
|
170
|
+
serverIconHandler,
|
|
139
171
|
};
|
|
140
172
|
}
|
|
141
173
|
|
|
@@ -195,6 +227,10 @@ export function createPlayerApp(options = {}) {
|
|
|
195
227
|
meta: { category: "server", resource: "status" },
|
|
196
228
|
});
|
|
197
229
|
|
|
230
|
+
app.get("/server/:address/icon", handlers.serverIconHandler, {
|
|
231
|
+
meta: { category: "server", resource: "icon" },
|
|
232
|
+
});
|
|
233
|
+
|
|
198
234
|
return { app, handlers };
|
|
199
235
|
}
|
|
200
236
|
|
|
@@ -204,7 +240,7 @@ export const playerPlugin = definePlugin((app) => {
|
|
|
204
240
|
});
|
|
205
241
|
|
|
206
242
|
function requireAccessToken(event) {
|
|
207
|
-
const header = event
|
|
243
|
+
const header = getRequestHeader(event, "authorization") ?? "";
|
|
208
244
|
if (!header.toLowerCase().startsWith("bearer ")) {
|
|
209
245
|
throw new MinecraftToolkitError("Authorization header with Bearer token is required", {
|
|
210
246
|
statusCode: 401,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { fetchJavaServerStatus } from "./status.js";
|
|
3
|
+
import { MinecraftToolkitError } from "../errors.js";
|
|
4
|
+
|
|
5
|
+
export async function fetchServerIcon(address, options = {}) {
|
|
6
|
+
const status = await fetchJavaServerStatus(address, options);
|
|
7
|
+
const favicon = status?.favicon ?? status?.raw?.favicon;
|
|
8
|
+
if (!favicon || typeof favicon !== "string") {
|
|
9
|
+
throw new MinecraftToolkitError("Server did not expose a favicon", { statusCode: 404 });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const base64 = extractBase64(favicon);
|
|
13
|
+
const buffer = Buffer.from(base64, "base64");
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
host: status.host,
|
|
17
|
+
port: status.port,
|
|
18
|
+
dataUri: `data:image/png;base64,${base64}`,
|
|
19
|
+
base64,
|
|
20
|
+
buffer,
|
|
21
|
+
byteLength: buffer.byteLength,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function extractBase64(favicon) {
|
|
26
|
+
const prefix = "data:image/png;base64,";
|
|
27
|
+
return favicon.startsWith(prefix) ? favicon.slice(prefix.length) : favicon;
|
|
28
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { Socket } from "node:net";
|
|
2
|
+
import {
|
|
3
|
+
createPublicKey,
|
|
4
|
+
publicEncrypt,
|
|
5
|
+
constants as cryptoConstants,
|
|
6
|
+
createHmac,
|
|
7
|
+
randomBytes,
|
|
8
|
+
} from "node:crypto";
|
|
9
|
+
import { DEFAULT_TIMEOUT_MS, DEFAULT_VOTIFIER_PORT } from "../../constants.js";
|
|
10
|
+
import { MinecraftToolkitError } from "../../errors.js";
|
|
11
|
+
import { normalizeAddress, normalizeUsername } from "../../utils/validation.js";
|
|
12
|
+
import { resolveTimeout, makeError } from "../shared.js";
|
|
13
|
+
|
|
14
|
+
const HANDSHAKE_PREFIX = "VOTIFIER";
|
|
15
|
+
|
|
16
|
+
const PROTOCOL_V1 = "v1";
|
|
17
|
+
const PROTOCOL_V2 = "v2";
|
|
18
|
+
|
|
19
|
+
export async function sendVotifierVote(options = {}) {
|
|
20
|
+
const {
|
|
21
|
+
host,
|
|
22
|
+
port = DEFAULT_VOTIFIER_PORT,
|
|
23
|
+
publicKey,
|
|
24
|
+
serviceName,
|
|
25
|
+
username,
|
|
26
|
+
address,
|
|
27
|
+
timestamp = Date.now(),
|
|
28
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
29
|
+
token,
|
|
30
|
+
protocol = "auto",
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
if (!host) {
|
|
34
|
+
throw new MinecraftToolkitError("Votifier host is required", { statusCode: 400 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!serviceName || typeof serviceName !== "string") {
|
|
38
|
+
throw new MinecraftToolkitError("Service name is required", { statusCode: 400 });
|
|
39
|
+
}
|
|
40
|
+
if (!username || typeof username !== "string") {
|
|
41
|
+
throw new MinecraftToolkitError("Username is required", { statusCode: 400 });
|
|
42
|
+
}
|
|
43
|
+
if (!address || typeof address !== "string") {
|
|
44
|
+
throw new MinecraftToolkitError("Voter IP address is required", { statusCode: 400 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const normalizedHost = normalizeAddress(host);
|
|
48
|
+
const normalizedUsername = normalizeUsername(username);
|
|
49
|
+
const sanitizedService = serviceName.trim();
|
|
50
|
+
const sanitizedAddress = address.trim();
|
|
51
|
+
if (!sanitizedService) {
|
|
52
|
+
throw new MinecraftToolkitError("Service name cannot be empty", { statusCode: 400 });
|
|
53
|
+
}
|
|
54
|
+
if (!sanitizedAddress) {
|
|
55
|
+
throw new MinecraftToolkitError("Voter IP address cannot be empty", { statusCode: 400 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const resolvedPort = Number.isInteger(port) ? port : DEFAULT_VOTIFIER_PORT;
|
|
59
|
+
const resolvedTimeout = resolveTimeout(timeoutMs);
|
|
60
|
+
const normalizedProtocol = normalizeProtocol(protocol);
|
|
61
|
+
const hasPublicKey = typeof publicKey === "string" && publicKey.trim().length > 0;
|
|
62
|
+
const hasToken = typeof token === "string" && token.trim().length > 0;
|
|
63
|
+
|
|
64
|
+
if (normalizedProtocol === PROTOCOL_V1 && !hasPublicKey) {
|
|
65
|
+
throw new MinecraftToolkitError("Votifier public key is required for protocol v1", {
|
|
66
|
+
statusCode: 400,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (normalizedProtocol === PROTOCOL_V2 && !hasToken) {
|
|
70
|
+
throw new MinecraftToolkitError("Votifier token is required for protocol v2", {
|
|
71
|
+
statusCode: 400,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (normalizedProtocol === "auto" && !hasPublicKey && !hasToken) {
|
|
75
|
+
throw new MinecraftToolkitError("Either a public key or token must be provided", {
|
|
76
|
+
statusCode: 400,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const unixSeconds =
|
|
81
|
+
typeof timestamp === "number"
|
|
82
|
+
? Math.floor(timestamp / 1000)
|
|
83
|
+
: Math.floor(timestamp.getTime() / 1000);
|
|
84
|
+
const voteString = buildVotePayload({
|
|
85
|
+
serviceName: sanitizedService,
|
|
86
|
+
username: normalizedUsername,
|
|
87
|
+
address: sanitizedAddress,
|
|
88
|
+
timestamp: unixSeconds,
|
|
89
|
+
});
|
|
90
|
+
const voteBufferV1 = hasPublicKey
|
|
91
|
+
? encryptVotePayload(Buffer.from(voteString, "utf8"), publicKey)
|
|
92
|
+
: null;
|
|
93
|
+
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const socket = new Socket();
|
|
96
|
+
socket.setNoDelay?.(true);
|
|
97
|
+
|
|
98
|
+
let settled = false;
|
|
99
|
+
let handshakeBuffer = Buffer.alloc(0);
|
|
100
|
+
let handshakeReceived = false;
|
|
101
|
+
let reportedVersion = null;
|
|
102
|
+
let voteDispatched = false;
|
|
103
|
+
let selectedProtocol = normalizedProtocol === "auto" ? null : normalizedProtocol;
|
|
104
|
+
|
|
105
|
+
function cleanup() {
|
|
106
|
+
socket.removeAllListeners();
|
|
107
|
+
socket.destroy();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveOnce(value) {
|
|
111
|
+
if (settled) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
settled = true;
|
|
115
|
+
cleanup();
|
|
116
|
+
resolve(value);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rejectOnce(error) {
|
|
120
|
+
if (settled) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
settled = true;
|
|
124
|
+
cleanup();
|
|
125
|
+
reject(
|
|
126
|
+
error instanceof MinecraftToolkitError
|
|
127
|
+
? error
|
|
128
|
+
: makeError(`Unable to send Votifier vote to ${normalizedHost}:${resolvedPort}`, error),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
socket.setTimeout(resolvedTimeout, () => {
|
|
133
|
+
rejectOnce(
|
|
134
|
+
new MinecraftToolkitError(
|
|
135
|
+
`Timed out while sending vote to ${normalizedHost}:${resolvedPort}`,
|
|
136
|
+
{
|
|
137
|
+
statusCode: 504,
|
|
138
|
+
},
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
socket.once("error", (error) => {
|
|
144
|
+
rejectOnce(makeError(`Unable to connect to ${normalizedHost}:${resolvedPort}`, error));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
socket.on("data", (chunk) => {
|
|
148
|
+
handshakeBuffer = Buffer.concat([handshakeBuffer, chunk]);
|
|
149
|
+
const handshakeText = handshakeBuffer.toString("utf8").trim();
|
|
150
|
+
if (!handshakeText.startsWith(HANDSHAKE_PREFIX)) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
handshakeReceived = true;
|
|
154
|
+
|
|
155
|
+
const { version, challenge } = parseHandshake(handshakeText);
|
|
156
|
+
reportedVersion = version;
|
|
157
|
+
|
|
158
|
+
if (!selectedProtocol || selectedProtocol === "auto") {
|
|
159
|
+
if (version?.startsWith("2") && hasToken) {
|
|
160
|
+
selectedProtocol = PROTOCOL_V2;
|
|
161
|
+
} else if (hasPublicKey) {
|
|
162
|
+
selectedProtocol = PROTOCOL_V1;
|
|
163
|
+
} else if (hasToken) {
|
|
164
|
+
selectedProtocol = PROTOCOL_V2;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (selectedProtocol === PROTOCOL_V1 && !hasPublicKey) {
|
|
169
|
+
rejectOnce(
|
|
170
|
+
new MinecraftToolkitError("Server expects Votifier v1 but no public key was provided", {
|
|
171
|
+
statusCode: 400,
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (selectedProtocol === PROTOCOL_V2 && !hasToken) {
|
|
177
|
+
rejectOnce(
|
|
178
|
+
new MinecraftToolkitError("Server expects Votifier v2 but no token was provided", {
|
|
179
|
+
statusCode: 400,
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
sendVote(challenge);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
socket.on("close", () => {
|
|
189
|
+
if (settled) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!handshakeReceived || !voteDispatched) {
|
|
193
|
+
rejectOnce(
|
|
194
|
+
new MinecraftToolkitError("Connection closed before Votifier vote was sent", {
|
|
195
|
+
statusCode: 502,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
resolveOnce({
|
|
201
|
+
acknowledged: true,
|
|
202
|
+
version: reportedVersion,
|
|
203
|
+
protocol: selectedProtocol ?? PROTOCOL_V1,
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
socket.connect(resolvedPort, normalizedHost);
|
|
208
|
+
|
|
209
|
+
function sendVote(challengeSegment) {
|
|
210
|
+
socket.removeAllListeners("data");
|
|
211
|
+
try {
|
|
212
|
+
voteDispatched = true;
|
|
213
|
+
if (selectedProtocol === PROTOCOL_V2) {
|
|
214
|
+
const payload = buildV2Payload({
|
|
215
|
+
serviceName: sanitizedService,
|
|
216
|
+
username: normalizedUsername,
|
|
217
|
+
address: sanitizedAddress,
|
|
218
|
+
timestamp: unixSeconds,
|
|
219
|
+
token,
|
|
220
|
+
challengeSegment,
|
|
221
|
+
});
|
|
222
|
+
socket.write(payload, (writeError) => {
|
|
223
|
+
if (writeError) {
|
|
224
|
+
rejectOnce(makeError("Failed to deliver Votifier v2 payload", writeError));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
socket.once("data", (response) => {
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(response.toString("utf8"));
|
|
230
|
+
if (parsed?.status === "error") {
|
|
231
|
+
rejectOnce(
|
|
232
|
+
new MinecraftToolkitError(parsed.errorMessage || "Votifier v2 error", {
|
|
233
|
+
cause: parsed,
|
|
234
|
+
statusCode: 502,
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
} else {
|
|
238
|
+
socket.end();
|
|
239
|
+
}
|
|
240
|
+
} catch (parseError) {
|
|
241
|
+
rejectOnce(makeError("Invalid Votifier v2 response", parseError));
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
socket.write(voteBufferV1, (writeError) => {
|
|
249
|
+
if (writeError) {
|
|
250
|
+
rejectOnce(makeError("Failed to deliver Votifier payload", writeError));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
socket.end();
|
|
254
|
+
});
|
|
255
|
+
} catch (sendError) {
|
|
256
|
+
rejectOnce(makeError("Failed to send Votifier payload", sendError));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildVotePayload({ serviceName, username, address, timestamp }) {
|
|
263
|
+
return `VOTE\n${serviceName}\n${username}\n${address}\n${timestamp}\n`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function buildV2Payload({ serviceName, username, address, timestamp, token, challengeSegment }) {
|
|
267
|
+
const normalizedChallenge = challengeSegment?.trim() || randomBytes(16).toString("hex");
|
|
268
|
+
const vote = {
|
|
269
|
+
serviceName,
|
|
270
|
+
username,
|
|
271
|
+
address,
|
|
272
|
+
timestamp,
|
|
273
|
+
challenge: normalizedChallenge,
|
|
274
|
+
};
|
|
275
|
+
const payload = JSON.stringify(vote);
|
|
276
|
+
const signature = createHmac("sha256", token).update(payload).digest("base64");
|
|
277
|
+
const message = JSON.stringify({ payload, signature });
|
|
278
|
+
const buffer = Buffer.alloc(4 + Buffer.byteLength(message));
|
|
279
|
+
buffer.writeUInt16BE(0x733a, 0);
|
|
280
|
+
buffer.writeUInt16BE(Buffer.byteLength(message), 2);
|
|
281
|
+
buffer.write(message, 4, "utf8");
|
|
282
|
+
return buffer;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function encryptVotePayload(payload, publicKey) {
|
|
286
|
+
let key;
|
|
287
|
+
try {
|
|
288
|
+
key = createPublicKey(
|
|
289
|
+
publicKey.includes("BEGIN")
|
|
290
|
+
? publicKey
|
|
291
|
+
: `-----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----`,
|
|
292
|
+
);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
throw new MinecraftToolkitError("Invalid Votifier public key", {
|
|
295
|
+
statusCode: 400,
|
|
296
|
+
cause: error,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
return publicEncrypt(
|
|
302
|
+
{
|
|
303
|
+
key,
|
|
304
|
+
padding: cryptoConstants.RSA_PKCS1_PADDING,
|
|
305
|
+
},
|
|
306
|
+
payload,
|
|
307
|
+
);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
throw new MinecraftToolkitError("Failed to encrypt Votifier payload", {
|
|
310
|
+
statusCode: 400,
|
|
311
|
+
cause: error,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function parseHandshake(text) {
|
|
317
|
+
const parts = text.split(" ");
|
|
318
|
+
return {
|
|
319
|
+
version: parts[1] ?? null,
|
|
320
|
+
challenge: parts[2] ?? null,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function normalizeProtocol(protocol) {
|
|
325
|
+
return protocol === PROTOCOL_V1 || protocol === PROTOCOL_V2 || protocol === "auto"
|
|
326
|
+
? protocol
|
|
327
|
+
: "auto";
|
|
328
|
+
}
|