Skip to content

Commit 5135087

Browse files
committed
Only allow pearl commands.
1 parent 5a213b6 commit 5135087

19 files changed

Lines changed: 339 additions & 286 deletions

README.md

Lines changed: 7 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,11 @@
1-
# ZenithProxy Web API Plugin
1+
## PearlPlusWebApi Plugin
22

3-
Runs a local web server that lets you interact with the ZenithProxy instance.
3+
Runs a local web server that executes PearlPlus commands in ZenithProxy to get around chat bans. Requires PearlPlus to be installed (obv).
44

5-
# Commands
5+
## Commands
66

7-
## `webApi`
7+
## `ppApi`
88

9-
* `webApi on/off` -> default: on
10-
* `webApi port <port>` -> default: 8080
11-
* `webApi auth <token>`
12-
13-
# HTTP API
14-
15-
## Authorization
16-
17-
All HTTP requests must have an `Authorization` header.
18-
19-
A default auth token is generated on first launch.
20-
21-
Or it can be set with the `webApi auth <token>` command.
22-
23-
## POST `/command`
24-
25-
### Request Body
26-
27-
```json
28-
{
29-
"command": "status"
30-
}
31-
```
32-
33-
### Response
34-
35-
```json
36-
{
37-
"embed": "\nZenithProxy 0.0.0 - Unknown\n\nStatus\nDisconnected\nConnected Player\nNone\nOnline For\nNot Online!\nHealth\n20.0\nDimension\nNone\nPing\n0ms\nProxy IP\nlocalhost\nServer\nconnect.2b2t.org:25565\nPriority Queue\nno [unbanned]\nSpectators\non\n2b2t Queue\nPriority: 15 [00:25:49]\nRegular: 688 [07:49:27]\nCoordinates\n||[0, 0, 0]||\nAutoUpdate\non",
38-
"embedComponent": "{\"color\":\"#E91E63\",\"extra\":[\"\\n\",{\"bold\":true,\"text\":\"ZenithProxy 0.0.0 - Unknown\"},\"\\n\",\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Status\"},{\"extra\":[\"Disconnected\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Connected Player\"},{\"extra\":[\"None\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Online For\"},{\"extra\":[\"Not Online!\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Health\"},{\"extra\":[\"20.0\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Dimension\"},{\"extra\":[\"None\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Ping\"},{\"extra\":[\"0ms\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Proxy IP\"},{\"extra\":[\"localhost\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Server\"},{\"extra\":[\"connect.2b2t.org:25565\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Priority Queue\"},{\"extra\":[\"no [unbanned]\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Spectators\"},{\"extra\":[\"on\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"2b2t Queue\"},{\"extra\":[\"Priority: 15 [00:25:49]\\nRegular: 688 [07:49:27]\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"Coordinates\"},{\"extra\":[\"||[0, 0, 0]||\"],\"text\":\"\"},\"\\n\",{\"bold\":true,\"extra\":[\"\\n\"],\"text\":\"AutoUpdate\"},{\"extra\":[\"on\"],\"text\":\"\"}],\"text\":\"\"}",
39-
"multiLineOutput": []
40-
}
41-
```
42-
43-
The `embedComponent` can be parsed back from json with [Kyori Adventure](https://docs.advntr.dev/getting-started.html)
44-
```java
45-
Component c = GsonComponentSerializer.gson().deserialize(embedComponent);
46-
```
47-
48-
Or with Minecraft's text components:
49-
```java
50-
// MC 1.21.1 mojmap
51-
MutableComponent component = Component.Serializer.fromJson(response.embedComponent(), Minecraft.getInstance().player.registryAccess());
52-
```
53-
54-
### Example
55-
56-
```bash
57-
curl --location 'http://localhost:8080/command' \
58-
--header 'Authorization: c05598ed-d123-4e8f-9aa7-40c11e657f23' \
59-
--header 'Content-Type: application/json' \
60-
--data '{"command":"status"}'
61-
```
62-
63-
# FAQ
64-
65-
## How do I call the API from the public internet?
66-
67-
Depends on where and how you are hosting the ZenithProxy instance.
68-
69-
It's the same as accessing the ZenithProxy MC server from the public internet.
70-
71-
So if you had to change firewall settings, port forwarding, or set up tunneling you'd do the same for the web API's port.
72-
73-
## I'm running multiple ZenithProxy instance on the same server, can they all have web APIs?
74-
75-
Yes, but each needs to be configured to use a different port: `webApi port <port>`
9+
* `ppApi on/off` -> default: on
10+
* `ppApi port <port>` -> default: 8080
11+
* `ppApi auth <token>`

gradle.properties

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
plugin_version=1.0.5
2-
plugin_name=ZenithProxyWebAPI
1+
plugin_version=1.0.0
2+
plugin_name=PearlPlusWebAPI
33
mc=1.21.4
4-
maven_group=dev.zenith.web
4+
maven_group=dev.zenith.ppapi
55

66
org.gradle.configuration-cache=true
77
org.gradle.parallel=true
88
org.gradle.caching=true
9-

src/main/java/dev/zenith/web/WebAPIConfig.java renamed to src/main/java/dev/zenith/ppapi/PPApiConfig.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
package dev.zenith.web;
1+
package dev.zenith.ppapi;
22

33
import java.util.UUID;
44

5-
public class WebAPIConfig {
5+
public class PPApiConfig {
66
public boolean enabled = true;
77
public int port = 8080;
88
public String authToken = UUID.randomUUID().toString();
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package dev.zenith.ppapi;
2+
3+
import com.zenith.plugin.api.Plugin;
4+
import com.zenith.plugin.api.PluginAPI;
5+
import com.zenith.plugin.api.ZenithProxyPlugin;
6+
import dev.zenith.ppapi.api.PPApiServer;
7+
import dev.zenith.ppapi.command.PPApiCommand;
8+
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
9+
10+
@Plugin(
11+
id = "pp-api",
12+
version = BuildConstants.VERSION,
13+
description = "PP API for ZenithProxy",
14+
url = "https://github.com/duccss/PearlPlusWebAPI",
15+
authors = {"duccss", "rfresh2"},
16+
mcVersions = {"*"}
17+
)
18+
public class PPApiPlugin implements ZenithProxyPlugin {
19+
public static PPApiConfig PLUGIN_CONFIG;
20+
public static ComponentLogger LOG;
21+
public static PPApiServer SERVER;
22+
23+
@Override
24+
public void onLoad(PluginAPI pluginAPI) {
25+
LOG = pluginAPI.getLogger();
26+
PLUGIN_CONFIG = pluginAPI.registerConfig("pp-api", PPApiConfig.class);
27+
SERVER = new PPApiServer();
28+
if (PLUGIN_CONFIG.enabled) {
29+
SERVER.start();
30+
}
31+
pluginAPI.registerCommand(new PPApiCommand());
32+
}
33+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package dev.zenith.ppapi.api;
2+
3+
import com.zenith.command.api.CommandContext;
4+
import com.zenith.command.api.CommandOutputHelper;
5+
import com.zenith.command.api.CommandSource;
6+
import com.zenith.discord.Embed;
7+
8+
public class PPApiCommandSource implements CommandSource {
9+
public static final PPApiCommandSource INSTANCE = new PPApiCommandSource();
10+
11+
@Override
12+
public String name() {
13+
return "PPApi";
14+
}
15+
16+
@Override
17+
public boolean validateAccountOwner(final CommandContext ctx) {
18+
return true;
19+
}
20+
21+
@Override
22+
public void logEmbed(final CommandContext commandContext, final Embed embed) {
23+
CommandOutputHelper.logEmbedOutputToTerminal(embed);
24+
}
25+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package dev.zenith.ppapi.api;
2+
3+
import com.fasterxml.jackson.databind.DeserializationFeature;
4+
import com.google.common.cache.Cache;
5+
import com.google.common.cache.CacheBuilder;
6+
import com.zenith.Globals;
7+
import com.zenith.command.api.CommandContext;
8+
import dev.zenith.ppapi.api.model.ApiErrorResponse;
9+
import dev.zenith.ppapi.api.model.PearlLoadRequest;
10+
import dev.zenith.ppapi.api.model.PearlLoadResponse;
11+
import dev.zenith.ppapi.api.model.PearlStatusRequest;
12+
import dev.zenith.ppapi.api.model.PearlStatusResponse;
13+
import io.javalin.Javalin;
14+
import io.javalin.json.JavalinJackson;
15+
import org.eclipse.jetty.util.thread.ExecutorThreadPool;
16+
17+
import java.util.ArrayList;
18+
import java.util.LinkedHashSet;
19+
import java.util.List;
20+
import java.util.Set;
21+
import java.util.concurrent.TimeUnit;
22+
23+
import static dev.zenith.ppapi.PPApiPlugin.LOG;
24+
import static dev.zenith.ppapi.PPApiPlugin.PLUGIN_CONFIG;
25+
26+
public class PPApiServer {
27+
private static final String REQUIRED_PLUGIN_ID = "pearlplus";
28+
private Javalin server;
29+
private final Cache<String, Integer> rateLimitCache = CacheBuilder.newBuilder()
30+
.expireAfterWrite(1, TimeUnit.MINUTES)
31+
.build();
32+
33+
public synchronized void start() {
34+
if (server != null) {
35+
stop();
36+
}
37+
if (!isRequiredPluginAvailable()) {
38+
LOG.warn("PP API not started. Required plugin '{}' is not loaded.", REQUIRED_PLUGIN_ID);
39+
return;
40+
}
41+
server = createServer();
42+
server.start(PLUGIN_CONFIG.port);
43+
LOG.info("PP API started on port {}", PLUGIN_CONFIG.port);
44+
LOG.info("Auth token: {}", PLUGIN_CONFIG.authToken);
45+
}
46+
47+
public synchronized void stop() {
48+
if (server != null) {
49+
server.stop();
50+
server = null;
51+
LOG.info("PP API stopped");
52+
}
53+
}
54+
55+
public synchronized boolean isRunning() {
56+
return server != null && server.jettyServer().started();
57+
}
58+
59+
private Javalin createServer() {
60+
return Javalin.create(config -> {
61+
var threadPool = new ExecutorThreadPool();
62+
threadPool.setDaemon(true);
63+
threadPool.setName("ZenithProxy-PPApi-%d");
64+
config.jetty.threadPool = threadPool;
65+
config.http.defaultContentType = "application/json";
66+
var objectMapper = JavalinJackson.defaultMapper()
67+
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
68+
config.jsonMapper(new JavalinJackson(objectMapper, false));
69+
})
70+
.beforeMatched(ctx -> {
71+
if (PLUGIN_CONFIG.rateLimiter) {
72+
String ip = ctx.ip();
73+
synchronized (this) {
74+
int reqCount = rateLimitCache.get(ip, () -> 0);
75+
rateLimitCache.put(ip, reqCount + 1);
76+
if (reqCount >= PLUGIN_CONFIG.rateLimitRequestsPerMinute) {
77+
ctx.status(429);
78+
ctx.json(new ApiErrorResponse("Rate limit exceeded"));
79+
ctx.skipRemainingHandlers();
80+
LOG.warn("Rate limit exceeded for IP: {}", ip);
81+
return;
82+
}
83+
}
84+
}
85+
if (!isRequiredPluginAvailable()) {
86+
ctx.status(503);
87+
ctx.json(new ApiErrorResponse("Required plugin is not loaded"));
88+
ctx.skipRemainingHandlers();
89+
LOG.warn("Denied request from {}: required plugin '{}' not loaded", ctx.ip(), REQUIRED_PLUGIN_ID);
90+
return;
91+
}
92+
var authHeaderValue = ctx.header("Authorization");
93+
if (authHeaderValue != null) {
94+
var expectedHeaderValue = PLUGIN_CONFIG.authToken;
95+
if (authHeaderValue.equals(expectedHeaderValue)) {
96+
// ok
97+
return;
98+
}
99+
}
100+
String reason = authHeaderValue == null
101+
? "Authorization header missing"
102+
: "Invalid auth token";
103+
ctx.json(new ApiErrorResponse(reason));
104+
ctx.status(401);
105+
ctx.skipRemainingHandlers();
106+
LOG.warn("Denied request from {}: {}", ctx.ip(), reason);
107+
})
108+
.post("/pearlplus/status", ctx -> {
109+
var req = ctx.bodyAsClass(PearlStatusRequest.class);
110+
var playerName = req.playerName();
111+
if (playerName == null || playerName.isBlank()) {
112+
ctx.status(400);
113+
ctx.json(new ApiErrorResponse("playerName is required"));
114+
return;
115+
}
116+
var result = readPearlsFromConfig(playerName);
117+
if (result.error() != null) {
118+
ctx.status(500);
119+
ctx.json(new ApiErrorResponse(result.error()));
120+
return;
121+
}
122+
ctx.json(new PearlStatusResponse(result.pearls(), result.output()));
123+
ctx.status(200);
124+
})
125+
.post("/pearlplus/load", ctx -> {
126+
var req = ctx.bodyAsClass(PearlLoadRequest.class);
127+
var playerName = req.playerName();
128+
var pearlId = req.pearlId();
129+
if (playerName == null || playerName.isBlank() || pearlId == null || pearlId.isBlank()) {
130+
ctx.status(400);
131+
ctx.json(new ApiErrorResponse("playerName and pearlId are required"));
132+
return;
133+
}
134+
var command = "pp load " + playerName + " " + pearlId;
135+
var context = executeCommand(command);
136+
ctx.json(new PearlLoadResponse("queued", context.getMultiLineOutput()));
137+
ctx.status(200);
138+
});
139+
}
140+
141+
private boolean isRequiredPluginAvailable() {
142+
return Globals.PLUGIN_MANAGER.getPlugin(REQUIRED_PLUGIN_ID) != null;
143+
}
144+
145+
private CommandContext executeCommand(String command) {
146+
var context = CommandContext.create(command, PPApiCommandSource.INSTANCE);
147+
LOG.info("PP API executed command: {}", command);
148+
Globals.COMMAND.execute(context);
149+
context.getSource().logEmbed(context, context.getEmbed());
150+
return context;
151+
}
152+
153+
private ConfigPearlResult readPearlsFromConfig(String playerName) {
154+
var plugin = Globals.PLUGIN_MANAGER.getPlugin(REQUIRED_PLUGIN_ID);
155+
if (plugin == null) {
156+
return new ConfigPearlResult(List.of(), List.of(), "PearlPlus plugin is not loaded");
157+
}
158+
try {
159+
var pluginClass = plugin.getClass();
160+
var configField = pluginClass.getDeclaredField("PLUGIN_CONFIG");
161+
configField.setAccessible(true);
162+
var config = configField.get(null);
163+
if (config == null) {
164+
return new ConfigPearlResult(List.of(), List.of(), "PearlPlus config not available");
165+
}
166+
var playersField = config.getClass().getDeclaredField("players");
167+
playersField.setAccessible(true);
168+
var playersObj = playersField.get(config);
169+
if (!(playersObj instanceof java.util.Map<?, ?> players)) {
170+
return new ConfigPearlResult(List.of(), List.of(), "PearlPlus config players map missing");
171+
}
172+
Set<String> pearls = new LinkedHashSet<>();
173+
for (var entry : players.values()) {
174+
if (entry == null) {
175+
continue;
176+
}
177+
var entryClass = entry.getClass();
178+
var nameField = entryClass.getDeclaredField("playerName");
179+
nameField.setAccessible(true);
180+
var nameObj = nameField.get(entry);
181+
if (nameObj == null) {
182+
continue;
183+
}
184+
var name = nameObj.toString();
185+
if (!name.equalsIgnoreCase(playerName)) {
186+
continue;
187+
}
188+
var pearlsField = entryClass.getDeclaredField("pearls");
189+
pearlsField.setAccessible(true);
190+
var pearlsObj = pearlsField.get(entry);
191+
if (pearlsObj instanceof java.util.Map<?, ?> pearlMap) {
192+
for (var pearlEntry : pearlMap.values()) {
193+
if (pearlEntry == null) {
194+
continue;
195+
}
196+
var pearlIdField = pearlEntry.getClass().getDeclaredField("pearlId");
197+
pearlIdField.setAccessible(true);
198+
var pearlIdObj = pearlIdField.get(pearlEntry);
199+
if (pearlIdObj != null) {
200+
pearls.add(pearlIdObj.toString());
201+
}
202+
}
203+
if (pearlMap.isEmpty()) {
204+
for (var key : pearlMap.keySet()) {
205+
if (key != null) {
206+
pearls.add(key.toString());
207+
}
208+
}
209+
}
210+
}
211+
}
212+
List<String> output = List.of("Loaded pearls from PearlPlus config");
213+
return new ConfigPearlResult(new ArrayList<>(pearls), output, null);
214+
} catch (ReflectiveOperationException e) {
215+
LOG.error("Failed to read PearlPlus config", e);
216+
return new ConfigPearlResult(List.of(), List.of(), "Failed to read PearlPlus config");
217+
}
218+
}
219+
220+
private record ConfigPearlResult(
221+
List<String> pearls,
222+
List<String> output,
223+
String error
224+
) { }
225+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package dev.zenith.ppapi.api.model;
2+
3+
public record ApiErrorResponse(
4+
String error
5+
) { }

0 commit comments

Comments
 (0)