minecraft-toolkit 0.1.3 → 1.0.0
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 +50 -7
- package/index.d.ts +55 -7
- package/index.js +9 -1
- package/package.json +18 -16
- package/src/constants.js +1 -0
- package/src/errors.js +1 -2
- package/src/h3/routes.js +49 -13
- package/src/player/resolve.js +2 -3
- package/src/server/icon.js +28 -0
- package/src/server/java/status.js +6 -5
- package/src/server/votifier/index.js +328 -0
- package/src/utils/network.js +8 -4
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
|
|
|
@@ -81,9 +81,10 @@ import {
|
|
|
81
81
|
|
|
82
82
|
isValidUsername("26bz"); // true
|
|
83
83
|
uuidWithDashes("069a79f444e94726a5befca90e38aaf5");
|
|
84
|
-
const
|
|
84
|
+
const profile = await fetchPlayerProfile("26bz");
|
|
85
|
+
const skinUrl = getSkinURL(profile);
|
|
85
86
|
const hash = extractTextureHash(skinUrl);
|
|
86
|
-
const model = getSkinModel(
|
|
87
|
+
const model = getSkinModel(profile); // "slim" | "default"
|
|
87
88
|
```
|
|
88
89
|
|
|
89
90
|
## Skin Metadata & Color Sampling
|
|
@@ -139,18 +140,60 @@ import {
|
|
|
139
140
|
fetchBedrockServerStatus,
|
|
140
141
|
} from "minecraft-toolkit";
|
|
141
142
|
|
|
142
|
-
const javaStatus = await fetchJavaServerStatus(
|
|
143
|
-
const bedrockStatus = await fetchBedrockServerStatus(
|
|
143
|
+
const javaStatus = await fetchJavaServerStatus("mc.hypixel.net", { port: 25565 });
|
|
144
|
+
const bedrockStatus = await fetchBedrockServerStatus("play.example.net", { port: 19132 });
|
|
144
145
|
|
|
145
146
|
// fetchServerStatus picks the right probe based on the `edition` field
|
|
146
|
-
const autoStatus = await fetchServerStatus(
|
|
147
|
+
const autoStatus = await fetchServerStatus("my.realm.net", { edition: "bedrock" });
|
|
147
148
|
|
|
148
|
-
console.log(javaStatus.players.online, bedrockStatus.motd
|
|
149
|
+
console.log(javaStatus.players.online, bedrockStatus.motd);
|
|
149
150
|
```
|
|
150
151
|
|
|
151
152
|
Both helpers normalize MOTD text, favicon/Base64 icons, latency, and version info. Errors surface as
|
|
152
153
|
`MinecraftToolkitError` with contextual status codes.
|
|
153
154
|
|
|
155
|
+
### Server Icon Helper
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { fetchServerIcon } from "minecraft-toolkit";
|
|
159
|
+
|
|
160
|
+
const icon = await fetchServerIcon("play.example.net");
|
|
161
|
+
console.log(icon.base64); // "iVBOR..."
|
|
162
|
+
console.log(icon.byteLength); // raw PNG size in bytes
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The helper reuses the Java status ping to extract the favicon, returning:
|
|
166
|
+
|
|
167
|
+
- `dataUri`: ready-to-render `data:image/png;base64,...`
|
|
168
|
+
- `base64`: raw Base64 payload
|
|
169
|
+
- `buffer` + `byteLength` for further processing (e.g., resizing, hashing)
|
|
170
|
+
|
|
171
|
+
If the server doesn’t expose an icon, it throws `MinecraftToolkitError` (404).
|
|
172
|
+
|
|
173
|
+
## Votifier Client (Java)
|
|
174
|
+
|
|
175
|
+
Send vote notifications to classic Votifier v1 (RSA public key) and NuVotifier v2 (token/HMAC) servers without re-implementing either protocol.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { sendVotifierVote } from "minecraft-toolkit";
|
|
179
|
+
|
|
180
|
+
const result = await sendVotifierVote({
|
|
181
|
+
host: "votifier.myserver.net",
|
|
182
|
+
port: 8192, // defaults to 8192 if omitted
|
|
183
|
+
publicKey: process.env.VOTIFIER_PUBLIC_KEY, // v1 servers
|
|
184
|
+
serviceName: "MyTopList",
|
|
185
|
+
username: "26bz",
|
|
186
|
+
address: "198.51.100.42",
|
|
187
|
+
token: listingSiteConfig.token, // v2 servers (optional)
|
|
188
|
+
protocol: "auto", // let the handshake decide between v1/v2
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
console.log(result.acknowledged, result.version, result.protocol);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
- 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.
|
|
195
|
+
- `timestamp` accepts a `Date` or millisecond value (default: `Date.now()`). All failures bubble as `MinecraftToolkitError`.
|
|
196
|
+
|
|
154
197
|
## Minecraft Formatting Renderer
|
|
155
198
|
|
|
156
199
|
Convert legacy `§` or `&` codes into safe HTML fragments or CSS class spans.
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { H3 } from "h3";
|
|
2
|
+
import type { Buffer } from "node:buffer";
|
|
2
3
|
|
|
3
4
|
export interface SkinTexture {
|
|
4
5
|
url: string;
|
|
@@ -70,7 +71,7 @@ export function fetchSkinMetadata(
|
|
|
70
71
|
sampleRegion?: { x?: number; y?: number; width?: number; height?: number };
|
|
71
72
|
},
|
|
72
73
|
): Promise<SkinMetadataResult>;
|
|
73
|
-
export function
|
|
74
|
+
export function computeSkinDominantColor(
|
|
74
75
|
url: string,
|
|
75
76
|
region?: { x?: number; y?: number; width?: number; height?: number },
|
|
76
77
|
): Promise<string | null>;
|
|
@@ -135,6 +136,7 @@ export interface BedrockServerStatusOptions {
|
|
|
135
136
|
|
|
136
137
|
export interface ServerStatusOptions extends JavaServerStatusOptions {
|
|
137
138
|
edition?: ServerEdition;
|
|
139
|
+
/** @deprecated Use `edition` instead. */
|
|
138
140
|
type?: ServerEdition;
|
|
139
141
|
}
|
|
140
142
|
|
|
@@ -182,7 +184,19 @@ export interface BedrockServerStatus {
|
|
|
182
184
|
|
|
183
185
|
export type ServerStatus = JavaServerStatus | BedrockServerStatus;
|
|
184
186
|
|
|
185
|
-
export
|
|
187
|
+
export interface ServerIconResult {
|
|
188
|
+
host: string;
|
|
189
|
+
port: number;
|
|
190
|
+
dataUri: string;
|
|
191
|
+
base64: string;
|
|
192
|
+
buffer: Buffer;
|
|
193
|
+
byteLength: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function fetchServerStatus(
|
|
197
|
+
address: string,
|
|
198
|
+
options?: ServerStatusOptions,
|
|
199
|
+
): Promise<ServerStatus>;
|
|
186
200
|
export function fetchJavaServerStatus(
|
|
187
201
|
address: string,
|
|
188
202
|
options?: JavaServerStatusOptions,
|
|
@@ -191,13 +205,47 @@ export function fetchBedrockServerStatus(
|
|
|
191
205
|
address: string,
|
|
192
206
|
options?: BedrockServerStatusOptions,
|
|
193
207
|
): Promise<BedrockServerStatus>;
|
|
208
|
+
export function fetchServerIcon(
|
|
209
|
+
address: string,
|
|
210
|
+
options?: JavaServerStatusOptions,
|
|
211
|
+
): Promise<ServerIconResult>;
|
|
212
|
+
|
|
213
|
+
export interface VotifierVoteOptions {
|
|
214
|
+
host: string;
|
|
215
|
+
port?: number;
|
|
216
|
+
publicKey: string;
|
|
217
|
+
serviceName: string;
|
|
218
|
+
username: string;
|
|
219
|
+
address: string;
|
|
220
|
+
timestamp?: number | Date;
|
|
221
|
+
timeoutMs?: number;
|
|
222
|
+
token?: string;
|
|
223
|
+
protocol?: "auto" | "v1" | "v2";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface VotifierVoteResult {
|
|
227
|
+
acknowledged: boolean;
|
|
228
|
+
version: string | null;
|
|
229
|
+
protocol: "v1" | "v2";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function sendVotifierVote(options: VotifierVoteOptions): Promise<VotifierVoteResult>;
|
|
194
233
|
|
|
195
234
|
export interface PlayerHandlers {
|
|
196
|
-
profileHandler:
|
|
197
|
-
skinHandler:
|
|
198
|
-
summaryHandler:
|
|
199
|
-
uuidHandler:
|
|
200
|
-
resolverHandler:
|
|
235
|
+
profileHandler: import("h3").EventHandler;
|
|
236
|
+
skinHandler: import("h3").EventHandler;
|
|
237
|
+
summaryHandler: import("h3").EventHandler;
|
|
238
|
+
uuidHandler: import("h3").EventHandler;
|
|
239
|
+
resolverHandler: import("h3").EventHandler;
|
|
240
|
+
nameHistoryHandler: import("h3").EventHandler;
|
|
241
|
+
existsHandler: import("h3").EventHandler;
|
|
242
|
+
batchHandler: import("h3").EventHandler;
|
|
243
|
+
nameChangeInfoHandler: import("h3").EventHandler;
|
|
244
|
+
nameAvailabilityHandler: import("h3").EventHandler;
|
|
245
|
+
giftCodeValidationHandler: import("h3").EventHandler;
|
|
246
|
+
blockedServersHandler: import("h3").EventHandler;
|
|
247
|
+
serverStatusHandler: import("h3").EventHandler;
|
|
248
|
+
serverIconHandler: import("h3").EventHandler;
|
|
201
249
|
}
|
|
202
250
|
|
|
203
251
|
export function createPlayerHandlers(): PlayerHandlers;
|
package/index.js
CHANGED
|
@@ -10,7 +10,13 @@ export {
|
|
|
10
10
|
hasSkinChanged,
|
|
11
11
|
} from "./src/player/profile/index.js";
|
|
12
12
|
export { fetchSkinMetadata, computeSkinDominantColor } from "./src/player/skin.js";
|
|
13
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
isValidUsername,
|
|
15
|
+
isUUID,
|
|
16
|
+
normalizeUUID,
|
|
17
|
+
uuidWithDashes,
|
|
18
|
+
uuidWithoutDashes,
|
|
19
|
+
} from "./src/player/identity/index.js";
|
|
14
20
|
export { getSkinURL, getCapeURL, getSkinModel, extractTextureHash } from "./src/player/textures.js";
|
|
15
21
|
export { resolvePlayer } from "./src/player/resolve.js";
|
|
16
22
|
export {
|
|
@@ -33,3 +39,5 @@ export {
|
|
|
33
39
|
convertPrefix,
|
|
34
40
|
getMaps,
|
|
35
41
|
} from "./src/utils/formatting.js";
|
|
42
|
+
export { sendVotifierVote } from "./src/server/votifier/index.js";
|
|
43
|
+
export { fetchServerIcon } from "./src/server/icon.js";
|
package/package.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minecraft-toolkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
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": {
|
|
@@ -43,17 +47,6 @@
|
|
|
43
47
|
"default": "./index.js"
|
|
44
48
|
}
|
|
45
49
|
},
|
|
46
|
-
"scripts": {
|
|
47
|
-
"test": "vitest",
|
|
48
|
-
"test:watch": "vitest watch",
|
|
49
|
-
"test:coverage": "vitest run --coverage",
|
|
50
|
-
"lint": "oxlint",
|
|
51
|
-
"lint:fix": "oxlint --fix",
|
|
52
|
-
"fmt": "oxfmt",
|
|
53
|
-
"fmt:check": "oxfmt --check",
|
|
54
|
-
"prepare": "husky install",
|
|
55
|
-
"docs:sync": "automd"
|
|
56
|
-
},
|
|
57
50
|
"dependencies": {
|
|
58
51
|
"h3": "2.0.1-rc.14",
|
|
59
52
|
"pngjs": "^7.0.0"
|
|
@@ -72,5 +65,14 @@
|
|
|
72
65
|
"engines": {
|
|
73
66
|
"node": ">=18"
|
|
74
67
|
},
|
|
75
|
-
"
|
|
76
|
-
|
|
68
|
+
"scripts": {
|
|
69
|
+
"test": "vitest",
|
|
70
|
+
"test:watch": "vitest watch",
|
|
71
|
+
"test:coverage": "vitest run --coverage",
|
|
72
|
+
"lint": "oxlint",
|
|
73
|
+
"lint:fix": "oxlint --fix",
|
|
74
|
+
"fmt": "oxfmt",
|
|
75
|
+
"fmt:check": "oxfmt --check",
|
|
76
|
+
"docs:sync": "automd"
|
|
77
|
+
}
|
|
78
|
+
}
|
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/errors.js
CHANGED
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,
|
package/src/player/resolve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isUUID, normalizeUUID, uuidWithDashes } from "./identity/index.js";
|
|
2
|
-
import { fetchPlayerProfile,
|
|
2
|
+
import { fetchPlayerProfile, fetchUsernameByUUID } from "./profile/index.js";
|
|
3
3
|
|
|
4
4
|
export async function resolvePlayer(input) {
|
|
5
5
|
if (typeof input !== "string" || input.trim().length === 0) {
|
|
@@ -21,9 +21,8 @@ export async function resolvePlayer(input) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const profile = await fetchPlayerProfile(raw);
|
|
24
|
-
const { id } = await fetchPlayerUUID(raw);
|
|
25
24
|
return {
|
|
26
|
-
id: uuidWithDashes(id),
|
|
25
|
+
id: uuidWithDashes(profile.id),
|
|
27
26
|
name: profile.name,
|
|
28
27
|
skin: profile.skin ?? null,
|
|
29
28
|
cape: profile.cape ?? null,
|
|
@@ -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
|
+
}
|
|
@@ -218,17 +218,18 @@ function parseStatusPayload(payload) {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
function buildJavaStatus(payload, host, port, latencyMs) {
|
|
221
|
+
const { version, players, description, favicon, ...rest } = payload;
|
|
221
222
|
return {
|
|
222
223
|
edition: "java",
|
|
223
224
|
online: true,
|
|
224
225
|
host,
|
|
225
226
|
port,
|
|
226
|
-
version:
|
|
227
|
-
players:
|
|
228
|
-
motd: stringifyDescription(
|
|
229
|
-
favicon:
|
|
227
|
+
version: version ?? null,
|
|
228
|
+
players: players ?? null,
|
|
229
|
+
motd: stringifyDescription(description) ?? null,
|
|
230
|
+
favicon: favicon ?? null,
|
|
230
231
|
latencyMs,
|
|
231
|
-
raw:
|
|
232
|
+
raw: rest,
|
|
232
233
|
};
|
|
233
234
|
}
|
|
234
235
|
|
|
@@ -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
|
+
}
|
package/src/utils/network.js
CHANGED
|
@@ -5,10 +5,14 @@ export function resolveAddress(address, overridePort, fallbackPort) {
|
|
|
5
5
|
return { host: address, port: validatePort(overridePort) };
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
// Only treat as "host:port" when there is exactly one colon.
|
|
9
|
+
const colonCount = (address.match(/:/g) ?? []).length;
|
|
10
|
+
if (colonCount === 1) {
|
|
11
|
+
const lastColon = address.lastIndexOf(":");
|
|
12
|
+
const potentialPort = address.slice(lastColon + 1);
|
|
13
|
+
if (potentialPort !== "") {
|
|
14
|
+
return { host: address.slice(0, lastColon), port: validatePort(potentialPort) };
|
|
15
|
+
}
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
return { host: address, port: fallbackPort };
|