Skip to content

Commit 1013f07

Browse files
committed
web api plugin
1 parent 1bc79fc commit 1013f07

18 files changed

Lines changed: 305 additions & 326 deletions

README.md

Lines changed: 45 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,69 @@
1-
# ZenithProxy Example Plugin
1+
# ZenithProxy Web API Plugin
22

3-
[ZenithProxy](https://github.com/rfresh2/ZenithProxy) is a Minecraft proxy and bot.
3+
Runs a local web server that lets you interact with the ZenithProxy instance.
44

5-
This repository is an example core plugin for ZenithProxy, allowing you to add custom modules and commands.
5+
# Commands
66

7-
## Installing Plugins
7+
## `webApi`
88

9-
Plugins are only supported on the `java` ZenithProxy release channel (i.e. not `linux`).
9+
* `webApi on/off` -> default: on
10+
* `webApi port <port>` -> default: 8080
11+
* `webApi auth <token>`
1012

11-
Place plugin jars in the `plugins` folder inside the same folder as the ZenithProxy launcher.
13+
# HTTP API
1214

13-
Restart ZenithProxy to load plugins. Loading plugins after launch or hot reloading is not supported.
15+
## Authorization
1416

15-
## Creating Plugins
17+
All HTTP requests must have an `Authorization` header.
1618

17-
Use this repository as a template to create your own plugin repository.
19+
A default auth token is generated on first launch.
1820

19-
### Plugin Structure
21+
Or it can be set with the `webApi auth <token>` command.
2022

21-
Each plugin needs a main class that implements `ZenithProxyPlugin` and is annotated with `@Plugin`.
23+
## POST `/command`
2224

23-
Plugin metadata like its unique id, version, and supported MC versions is defined in the `@Plugin` annotation.
25+
### Request Body
2426

25-
[See example](https://github.com/rfresh2/ZenithProxyExamplePlugin/blob/1.21.0/src/main/java/org/example/ExamplePlugin.java)
27+
```json
28+
{
29+
"command": "status"
30+
}
31+
```
2632

27-
### Plugin API
33+
### Response
2834

29-
The `ZenithProxyPlugin` interface requires you to implement an `onLoad` method.
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+
```
3042

31-
This method provides a `PluginAPI` object that you can use to register modules, commands, and config files.
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+
```
3247

33-
`Module` and `Command` classes are implemented the same as in the ZenithProxy source code.
48+
### Example
3449

35-
I recommend looking at existing modules and commands for examples.
50+
```bash
51+
curl --location 'http://localhost:8080/command' \
52+
--header 'Authorization: c05598ed-d123-4e8f-9aa7-40c11e657f23' \
53+
--header 'Content-Type: application/json' \
54+
--data '{"command":"status"}'
55+
```
3656

37-
* [Module](https://github.com/rfresh2/ZenithProxy/tree/1.21.0/src/main/java/com/zenith/module)
38-
* [Command](https://github.com/rfresh2/ZenithProxy/tree/1.21.0/src/main/java/com/zenith/command)
57+
# FAQ
3958

40-
### Building Plugins
59+
## How do I call the API from the public internet?
4160

42-
Execute the Gradle `build` task: `./gradlew build` - or double-click the task in Intellij
61+
Depends on where and how you are hosting the ZenithProxy instance.
4362

44-
The built plugin jar will be in the `build/libs` directory.
63+
It's the same as accessing the ZenithProxy MC server from the public internet.
4564

46-
### Testing Plugins
65+
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.
4766

48-
Execute the `run` task: `./gradlew run` - or double-click the task in Intellij
67+
## I'm running multiple ZenithProxy instance on the same server, can they all have web APIs?
4968

50-
This will run ZenithProxy with your plugin loaded in the `run` directory.
51-
52-
### New Plugin Checklist
53-
54-
1. Edit `gradle.properties`:
55-
- `plugin_name` - Name of your plugin, used in the plugin jar name (e.g. `ExamplePlugin`)
56-
- `maven_group` - Java package for your project (e.g. `com.github.rfresh2`)
57-
1. Move files to your new corresponding package / maven group:
58-
- Example: `src/main/java/org/example` -> `src/main/java/com/github/rfresh2`
59-
- First create the new package in `src/main/java`. Then click and drag original subpackages/classes to your new one
60-
- Do this with Intellij to avoid manually editing all the source files
61-
- You must also move folders/package for the `src/main/templates` folder
62-
- Also make sure to update the package import at the very top of `BuiltConstants.java`, it will not be done automatically
63-
1. Edit `ExamplePlugin.java`, or remove it and create a new main class
64-
- Make sure to update the `@Plugin` annotation
69+
Yes, but each needs to be configured to use a different port: `webApi port <port>`

build.gradle.kts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ zenithProxyPlugin {
1515
}
1616

1717
repositories {
18+
mavenLocal()
1819
maven("https://maven.2b2t.vc/releases") {
1920
description = "ZenithProxy Releases and Dependencies"
2021
}
@@ -25,4 +26,19 @@ repositories {
2526

2627
dependencies {
2728
zenithProxy("com.zenith:ZenithProxy:$mc-SNAPSHOT")
29+
shade("io.javalin:javalin:6.6.0")
30+
}
31+
32+
tasks {
33+
shadowJar {
34+
val shadowPackage = "dev.zenith.web.shadow"
35+
relocate("io.javalin", "$shadowPackage.javalin")
36+
relocate("jakarta.servlet", "$shadowPackage.jakarta.servlet")
37+
relocate("kotlin", "$shadowPackage.kotlin")
38+
relocate("org.eclipse", "$shadowPackage.org.eclipse")
39+
exclude("org/slf4j/**")
40+
exclude("org/jetbrains/**")
41+
exclude("META-INF/maven/**")
42+
// todo: transform service files? seems to work fine without them for now
43+
}
2844
}

gradle.properties

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
plugin_version=1.0.0
2-
plugin_name=ZenithProxyExamplePlugin
2+
plugin_name=ZenithProxyWebAPI
33
mc=1.21.0
4-
maven_group=org.example
4+
maven_group=dev.zenith.web
55

66
org.gradle.configuration-cache=true
77
org.gradle.parallel=true
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package dev.zenith.web;
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 WebAPICommandSource extends CommandSource {
9+
public static final WebAPICommandSource INSTANCE = new WebAPICommandSource();
10+
public WebAPICommandSource() {
11+
super("WebAPI", () -> "");
12+
}
13+
14+
@Override
15+
public boolean validateAccountOwner(final CommandContext ctx) {
16+
ctx.getEmbed()
17+
.description("Web API is not authorized to execute this command!");
18+
return false;
19+
}
20+
21+
@Override
22+
public void logEmbed(final CommandContext commandContext, final Embed embed) {
23+
CommandOutputHelper.logEmbedOutputToTerminal(embed);
24+
}
25+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package dev.zenith.web;
2+
3+
import java.util.UUID;
4+
5+
public class WebAPIConfig {
6+
public boolean enabled = true;
7+
public int port = 8080;
8+
public String authToken = UUID.randomUUID().toString();
9+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package dev.zenith.web;
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.web.command.WebAPICommand;
7+
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
8+
9+
@Plugin(
10+
id = "web-api",
11+
version = BuildConstants.VERSION,
12+
description = "Web API for ZenithProxy",
13+
url = "https://github.com/rfresh2/ZenithProxyWebAPI",
14+
authors = {"rfresh2"},
15+
mcVersions = {"1.21.0"}
16+
)
17+
public class WebApiPlugin implements ZenithProxyPlugin {
18+
public static WebAPIConfig PLUGIN_CONFIG;
19+
public static ComponentLogger LOG;
20+
public static WebServer SERVER;
21+
22+
@Override
23+
public void onLoad(PluginAPI pluginAPI) {
24+
LOG = pluginAPI.getLogger();
25+
PLUGIN_CONFIG = pluginAPI.registerConfig("web-api", WebAPIConfig.class);
26+
SERVER = new WebServer();
27+
if (PLUGIN_CONFIG.enabled) {
28+
SERVER.start();
29+
}
30+
pluginAPI.registerCommand(new WebAPICommand());
31+
}
32+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package dev.zenith.web;
2+
3+
import com.zenith.Globals;
4+
import com.zenith.command.api.CommandContext;
5+
import com.zenith.discord.EmbedSerializer;
6+
import com.zenith.util.ComponentSerializer;
7+
import dev.zenith.web.model.AuthErrorResponse;
8+
import dev.zenith.web.model.CommandRequest;
9+
import dev.zenith.web.model.CommandResponse;
10+
import io.javalin.Javalin;
11+
12+
import java.util.List;
13+
14+
import static dev.zenith.web.WebApiPlugin.LOG;
15+
import static dev.zenith.web.WebApiPlugin.PLUGIN_CONFIG;
16+
17+
public class WebServer {
18+
private Javalin server;
19+
20+
public synchronized void start() {
21+
if (server != null) {
22+
stop();
23+
}
24+
initialize();
25+
server.start(PLUGIN_CONFIG.port);
26+
LOG.info("Web API started on port {}", PLUGIN_CONFIG.port);
27+
LOG.info("Auth token: {}", PLUGIN_CONFIG.authToken);
28+
}
29+
30+
public synchronized void stop() {
31+
if (server != null) {
32+
server.stop();
33+
server = null;
34+
LOG.info("Web API stopped");
35+
}
36+
}
37+
38+
public synchronized boolean isRunning() {
39+
return server != null && server.jettyServer().started();
40+
}
41+
42+
private void initialize() {
43+
server = Javalin.create(config -> {
44+
config.useVirtualThreads = true;
45+
config.http.defaultContentType = "application/json";
46+
})
47+
.beforeMatched(ctx -> {
48+
var authHeaderValue = ctx.header("Authorization");
49+
if (authHeaderValue != null) {
50+
var expectedHeaderValue = PLUGIN_CONFIG.authToken;
51+
if (authHeaderValue.equals(expectedHeaderValue)) {
52+
// ok
53+
return;
54+
}
55+
}
56+
String reason = authHeaderValue == null
57+
? "Authorization header missing"
58+
: "Invalid auth token";
59+
ctx.json(new AuthErrorResponse(reason));
60+
ctx.status(401);
61+
ctx.skipRemainingHandlers();
62+
LOG.warn("Denied request from {}: {}", ctx.ip(), reason);
63+
})
64+
.post("/command", ctx -> {
65+
var req = ctx.bodyAsClass(CommandRequest.class);
66+
var command = req.command();
67+
var context = CommandContext.create(command, WebAPICommandSource.INSTANCE);
68+
LOG.info("{} executed command: {}", ctx.ip(), command);
69+
Globals.COMMAND.execute(context);
70+
context.getSource().logEmbed(context, context.getEmbed());
71+
String embedResponse = null;
72+
String embedResponseComponent = null;
73+
List<String> multiLineResponse = context.getMultiLineOutput();
74+
if (context.getEmbed().isTitlePresent()) {
75+
var embedComponent = EmbedSerializer.serialize(context.getEmbed());
76+
var embedString = ComponentSerializer.serializePlain(embedComponent);
77+
embedResponse = embedString;
78+
embedResponseComponent = ComponentSerializer.serializeJson(embedComponent);
79+
}
80+
ctx.json(new CommandResponse(embedResponse, embedResponseComponent, multiLineResponse));
81+
ctx.status(200);
82+
});
83+
}
84+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package dev.zenith.web.command;
2+
3+
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
4+
import com.zenith.command.api.Command;
5+
import com.zenith.command.api.CommandCategory;
6+
import com.zenith.command.api.CommandContext;
7+
import com.zenith.command.api.CommandUsage;
8+
import com.zenith.discord.Embed;
9+
10+
import static com.mojang.brigadier.arguments.IntegerArgumentType.getInteger;
11+
import static com.mojang.brigadier.arguments.IntegerArgumentType.integer;
12+
import static com.zenith.command.brigadier.CustomStringArgumentType.getString;
13+
import static com.zenith.command.brigadier.CustomStringArgumentType.wordWithChars;
14+
import static com.zenith.command.brigadier.ToggleArgumentType.getToggle;
15+
import static com.zenith.command.brigadier.ToggleArgumentType.toggle;
16+
import static dev.zenith.web.WebApiPlugin.PLUGIN_CONFIG;
17+
import static dev.zenith.web.WebApiPlugin.SERVER;
18+
19+
public class WebAPICommand extends Command {
20+
@Override
21+
public CommandUsage commandUsage() {
22+
return CommandUsage.builder()
23+
.name("webapi")
24+
.category(CommandCategory.MODULE)
25+
.description("""
26+
Manages the HTTP web API for interacting with this ZenithProxy instance.
27+
""")
28+
.usageLines(
29+
"on/off",
30+
"port <port>",
31+
"auth <token>"
32+
)
33+
.build();
34+
}
35+
36+
@Override
37+
public LiteralArgumentBuilder<CommandContext> register() {
38+
return command("webapi").requires(Command::validateAccountOwner)
39+
.then(argument("toggle", toggle()).executes(c -> {
40+
PLUGIN_CONFIG.enabled = getToggle(c, "toggle");
41+
if (PLUGIN_CONFIG.enabled) {
42+
SERVER.start();
43+
} else {
44+
SERVER.stop();
45+
}
46+
c.getSource().getEmbed()
47+
.title("Web API " + toggleStrCaps(PLUGIN_CONFIG.enabled));
48+
}))
49+
.then(literal("port").then(argument("portArg", integer(1, 65535)).executes(c -> {
50+
PLUGIN_CONFIG.port = getInteger(c, "portArg");
51+
if (PLUGIN_CONFIG.enabled) {
52+
SERVER.start();
53+
}
54+
c.getSource().getEmbed()
55+
.title("Port Set");
56+
})))
57+
.then(literal("auth").then(argument("token", wordWithChars()).executes(c -> {
58+
PLUGIN_CONFIG.authToken = getString(c, "token");
59+
c.getSource().getEmbed()
60+
.title("Auth Token Set");
61+
})));
62+
}
63+
64+
@Override
65+
public void defaultEmbed(Embed embed) {
66+
embed
67+
.addField("Web API", SERVER.isRunning() ? "Running" : "Stopped")
68+
.addField("Port", PLUGIN_CONFIG.port)
69+
.addField("Auth Token", PLUGIN_CONFIG.authToken)
70+
.primaryColor();
71+
}
72+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package dev.zenith.web.model;
2+
3+
public record AuthErrorResponse(
4+
String reason
5+
) { }
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package dev.zenith.web.model;
2+
3+
public record CommandRequest(
4+
String command
5+
) { }

0 commit comments

Comments
 (0)