schedule (#1)

Schedule kick

Signed-off-by: Etzelia <etzelia@hotmail.com>

Chat API (#7)

Chat API

Signed-off-by: Etzelia <etzelia@hotmail.com>

Reviewed-on: https://git.etztech.xyz/Minecraft/ServerAPI/pulls/7
Reviewed-by: ZeroHD <joey@ahines.net>

Add token auth and POST endpoints (#6)

Add token auth and POST endpoints

Signed-off-by: Etzelia <etzelia@hotmail.com>

Reviewed-on: https://git.etztech.xyz/Minecraft/ServerAPI/pulls/6
Reviewed-by: ZeroHD <joey@ahines.net>

Change QueryAPI to PingAPI and add PluginAPI (#4)

typo

Fix null fields

Signed-off-by: Etzelia <etzelia@hotmail.com>

Change QueryAPI to PingAPI and add PluginAPI

Signed-off-by: Etzelia <etzelia@hotmail.com>

Reviewed-on: https://git.etztech.xyz/Minecraft/ServerAPI/pulls/4
Reviewed-by: ZeroHD <joey@ahines.net>

Fix conflicting name (#3)

Fix conflicting name

Signed-off-by: Etzelia <etzelia@hotmail.com>

Reviewed-on: https://git.etztech.xyz/Minecraft/ServerAPI/pulls/3

Reviewed-on: https://git.birbmc.com/BirbMC/serverapi/pulls/1
Co-Authored-By: Etzelia <etzelia@hotmail.com>
Co-Committed-By: Etzelia <etzelia@hotmail.com>
master
Etzelia 2021-03-16 01:54:15 +00:00
parent d9490198b1
commit f2a6249af1
20 changed files with 555 additions and 41 deletions

View File

@ -3,7 +3,7 @@
<groupId>xyz.etztech</groupId>
<artifactId>ServerAPI</artifactId>
<!-- Version is used in plugin.yml -->
<version>0.0.1</version>
<version>0.0.3</version>
<packaging>jar</packaging>
<!-- Plugin Information -->

View File

@ -3,20 +3,20 @@ package xyz.etztech.serverapi;
import org.bukkit.BanEntry;
import org.bukkit.BanList;
import org.bukkit.OfflinePlayer;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import xyz.etztech.serverapi.commands.MainCommand;
import xyz.etztech.serverapi.listeners.AsyncPlayerChatListener;
import xyz.etztech.serverapi.token.TokenList;
import xyz.etztech.serverapi.tps.TPS;
import xyz.etztech.serverapi.web.IProvider;
import xyz.etztech.serverapi.web.Web;
import xyz.etztech.serverapi.web.api.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.*;
import java.util.logging.Logger;
public class ServerAPI extends JavaPlugin implements IProvider {
@ -26,6 +26,8 @@ public class ServerAPI extends JavaPlugin implements IProvider {
private Web web = new Web(this);
private final Logger log = Logger.getLogger( "Minecraft" );
private static final List<ChatAPI> chat = new ArrayList<>();
@Override
public void onEnable() {
instance = this;
@ -34,6 +36,7 @@ public class ServerAPI extends JavaPlugin implements IProvider {
reloadConfig();
if (isEnabled()) {
new AsyncPlayerChatListener(this);
new MainCommand(this);
tps = new TPS();
@ -52,7 +55,8 @@ public class ServerAPI extends JavaPlugin implements IProvider {
web.stop();
web.start(
getConfig().getInt("port", 8080),
getConfig().getString("password", "")
new TokenList(getConfig().getConfigurationSection("auth")),
getConfig().getStringList("custom")
);
}
@ -68,6 +72,10 @@ public class ServerAPI extends JavaPlugin implements IProvider {
return tps;
}
public static List<ChatAPI> getChat() {
return chat;
}
@Override
public TPSAPI TPS() {
return new TPSAPI(tps.getHistory());
@ -111,8 +119,64 @@ public class ServerAPI extends JavaPlugin implements IProvider {
}
@Override
public QueryAPI query() {
return QueryAPI.fromMinecraft(getServer());
public List<ChatAPI> chat() {
return chat;
}
@Override
public void kick(BanAPI kick) {
Player player = Bukkit.getPlayerExact(kick.getTarget());
if (player != null) {
getServer().getScheduler().runTask(this, () -> {
player.kickPlayer("You have been kicked: " + kick.getReason());
});
}
}
@Override
public void ban(BanAPI ban) {
Date expires = null;
if (ban.getExpiration() != 0) {
expires = new Date(ban.getExpiration());
}
Bukkit.getBanList(BanList.Type.NAME).addBan(ban.getTarget(), ban.getReason(), expires, "ServerAPI");
Player player = Bukkit.getPlayerExact(ban.getTarget());
if (player != null) {
getServer().getScheduler().runTask(this, () -> {
player.kickPlayer("You have been banned: " + ban.getReason());
});
}
}
@Override
public void unban(BanAPI ban) {
Bukkit.getBanList(BanList.Type.NAME).pardon(ban.getTarget());
}
@Override
public void broadcast(BroadcastAPI broadcast) {
Bukkit.broadcastMessage(String.format("%s > %s", broadcast.getFrom(), broadcast.getMessage()));
}
@Override
public void custom(CustomAPI custom) {
getServer().getScheduler().runTask(this, () -> {
getServer().dispatchCommand(getServer().getConsoleSender(), custom.build());
});
}
@Override
public PingAPI ping() {
return PingAPI.fromMinecraft(getServer());
}
@Override
public List<PluginAPI> plugins() {
List<PluginAPI> plugins = new ArrayList<>();
for (Plugin plugin : getServer().getPluginManager().getPlugins()) {
plugins.add(PluginAPI.fromMinecraft(plugin));
}
return plugins;
}
}

View File

@ -0,0 +1,31 @@
package xyz.etztech.serverapi.listeners;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import xyz.etztech.serverapi.ServerAPI;
import xyz.etztech.serverapi.web.api.ChatAPI;
import java.util.Date;
public class AsyncPlayerChatListener implements Listener {
private final ServerAPI plugin;
public AsyncPlayerChatListener(ServerAPI plugin) {
this.plugin = plugin;
plugin.getServer().getPluginManager().registerEvents(this, plugin);
}
@EventHandler(priority= EventPriority.MONITOR, ignoreCancelled=true)
public void onChat(AsyncPlayerChatEvent event) {
int chatLimit = plugin.getConfig().getInt("chat", 100);
if (ServerAPI.getChat().size() >= chatLimit) {
ServerAPI.getChat().remove(0);
}
ServerAPI.getChat().add(new ChatAPI(
String.format(event.getFormat(), event.getPlayer().getDisplayName(), event.getMessage()),
new Date().getTime()
));
}
}

View File

@ -0,0 +1,27 @@
package xyz.etztech.serverapi.token;
public class Token {
private final String token;
private final TokenScope scope;
public Token(String token, TokenScope scope) {
this.token = token;
this.scope = scope;
}
public String getToken() {
return token;
}
public TokenScope getScope() {
return scope;
}
public boolean canGET() {
return scope == TokenScope.GET || scope == TokenScope.ALL;
}
public boolean canPOST() {
return scope == TokenScope.POST || scope == TokenScope.ALL;
}
}

View File

@ -0,0 +1,49 @@
package xyz.etztech.serverapi.token;
import org.bukkit.configuration.ConfigurationSection;
import java.util.ArrayList;
import java.util.List;
public class TokenList {
private final boolean protectGET;
private final boolean protectPOST;
private final List<Token> tokens;
public TokenList(boolean protectGET, boolean protectPOST, List<Token> tokens) {
this.protectGET = protectGET;
this.protectPOST = protectPOST;
this.tokens = tokens;
}
public TokenList(ConfigurationSection auth) {
if (auth == null) {
this.protectGET = false;
this.protectPOST = false;
this.tokens = null;
return;
}
this.protectGET = auth.getBoolean("get", true);
this.protectPOST = auth.getBoolean("post", true);
this.tokens = new ArrayList<>();
ConfigurationSection tokenSection = auth.getConfigurationSection("tokens");
if (tokenSection == null) {
return;
}
for (String token : tokenSection.getKeys(false)) {
this.tokens.add(new Token(token, TokenScope.parseScope(tokenSection.getString(token, "none"))));
}
}
public boolean isProtectGET() {
return protectGET;
}
public boolean isProtectPOST() {
return protectPOST;
}
public List<Token> getTokens() {
return tokens;
}
}

View File

@ -0,0 +1,24 @@
package xyz.etztech.serverapi.token;
public enum TokenScope {
NONE,
GET,
POST,
ALL;
public static TokenScope parseScope(String scope) {
if (scope == null) {
return NONE;
}
switch (scope.toLowerCase()) {
case "get":
return GET;
case "post":
return POST;
case "all":
return ALL;
default:
return NONE;
}
}
}

View File

@ -21,9 +21,14 @@ public class GraphQL implements QueryGraphql {
return provider.players().toArray(new PlayerAPI[0]);
}
@GraphQLName("query")
public QueryAPI getQuery() {
return provider.query();
@GraphQLName("ping")
public PingAPI getPing() {
return provider.ping();
}
@GraphQLName("plugins")
public PluginAPI[] getPlugins() {
return provider.plugins().toArray(new PluginAPI[0]);
}
@GraphQLName("world")
@ -40,4 +45,9 @@ public class GraphQL implements QueryGraphql {
public TPSAPI getTps() {
return provider.TPS();
}
@GraphQLName("chat")
public ChatAPI[] getChat() {
return provider.chat().toArray(new ChatAPI[0]);
}
}

View File

@ -6,12 +6,24 @@ import java.util.List;
import java.util.Set;
public interface IProvider {
// GET
Set<BanAPI> bans();
Set<PlayerAPI> players();
QueryAPI query();
PingAPI ping();
TPSAPI TPS();
List<WorldAPI> worlds();
WorldAPI world(String name);
List<PluginAPI> plugins();
List<ChatAPI> chat();
// POST
void kick(BanAPI kick);
void ban(BanAPI ban);
void unban(BanAPI ban);
void broadcast(BroadcastAPI broadcast);
void custom(CustomAPI custom);
// MISC
void log(String message);
}

View File

@ -0,0 +1,22 @@
# Adding a new endpoint
1. Create a new `<endpoint>API` class in [api](api).
* Make sure to correctly add the appropriate JSON and GraphQL annotations.
Look at other classes for examples.
* **NOTE:** If anything returned could be null, make sure to instead provide an appropriate zero-value property,
otherwise GraphQL will choke.
See [PluginAPI::new](api/PluginAPI.java) for an example.
2. Add a method to return the needed data to [IProvider](IProvider.java).
3. Add a new REST endpoint to the [Web::start](Web.java) method.
4. Add a new GraphQL method to [GraphQL](GraphQL.java)
* Make sure to correctly add the appropriate GraphQL annotation.
5. Modify both [ServerAPI](../ServerAPI.java)
and [MockProvider](../../../../../../test/java/xyz/etztech/serverapi/MockProvider.java)
to fulfill the [IProvider](IProvider.java) interface.
# Testing
[ServerRunner](../../../../../../test/java/xyz/etztech/serverapi/ServerRunner.java) should start up the API using
[MockProvider](../../../../../../test/java/xyz/etztech/serverapi/MockProvider.java).
If possible, a real test on a running Minecraft server would ideal!

View File

@ -4,6 +4,7 @@ import io.javalin.Javalin;
import io.javalin.core.event.EventListener;
import io.javalin.core.plugin.Plugin;
import io.javalin.core.util.RouteOverviewPlugin;
import io.javalin.http.BadRequestResponse;
import io.javalin.http.Context;
import io.javalin.http.UnauthorizedResponse;
import io.javalin.plugin.graphql.GraphQLOptions;
@ -12,18 +13,25 @@ import io.javalin.plugin.json.JavalinJson;
import org.apache.commons.lang.exception.ExceptionUtils;
import org.slf4j.helpers.NOPLogger;
import xyz.etztech.serverapi.ServerAPI;
import xyz.etztech.serverapi.token.TokenList;
import xyz.etztech.serverapi.web.api.BanAPI;
import xyz.etztech.serverapi.web.api.BroadcastAPI;
import xyz.etztech.serverapi.web.api.CustomAPI;
import xyz.etztech.serverapi.web.api.ErrorAPI;
import java.util.List;
public class Web {
private final IProvider provider;
private String password;
private TokenList tokens;
private List<String> custom;
private Javalin app;
public Web(IProvider provider) {
this.provider = provider;
}
public void start(int port, String password) {
public void start(int port, TokenList tokens, List<String> custom) {
Javalin.log = NOPLogger.NOP_LOGGER;
app = Javalin.create(config -> {
config.registerPlugin(graphql());
@ -31,17 +39,33 @@ public class Web {
config.enableCorsForAllOrigins();
}).events(this::events).exception(Exception.class, this::exception);
this.password = password;
if (!"".equals(password)) {
this.custom = custom;
this.tokens = tokens;
if (tokens != null) {
provider.log(String.format("Loaded %d tokens", tokens.getTokens().size()));
if (!tokens.isProtectPOST()) {
provider.log("WARNING: You have disabled POST protection, which can enable users to arbitrarily run actions against your server.");
}
app.before(this::access);
}
// REST Endpoints
// For GraphQL endpoints, see GraphQL.java
app.get("/bans", this::bans);
app.get("/players", this::players);
app.get("/query", this::query);
app.get("/plugins", this::plugins);
app.get("/ping", this::ping);
app.get("/tps", this::tps);
app.get("/worlds", this::worlds);
app.get("/worlds/:name", this::world);
app.get("/chat", this::chat);
app.post("/kick", this::kick);
app.post("/ban", this::ban);
app.post("/unban", this::unban);
app.post("/broadcast", this::broadcast);
app.post("/custom", this::custom);
// What in the actual fuck... https://github.com/tipsy/javalin/issues/358
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
@ -74,13 +98,22 @@ public class Web {
}
public void access(Context ctx) {
String pw = ctx.header("X-ServerAPI-Password");
if (pw == null) {
pw = ctx.queryParam("password");
String token = ctx.header("X-ServerAPI-Token");
if (token == null) {
token = ctx.queryParam("token");
}
boolean isGET = "GET".equalsIgnoreCase(ctx.method());
boolean isPOST = "POST".equalsIgnoreCase(ctx.method());
if (!password.equals(pw)) {
throw new UnauthorizedResponse(JavalinJson.toJson(new ErrorAPI(401, "Unauthorized")));
if (isGET && !tokens.isProtectGET()) return;
if (isPOST && !tokens.isProtectPOST()) return;
String finalToken = token;
boolean pass = tokens.getTokens().stream().anyMatch(t -> t.getToken().equals(finalToken) &&
((isGET && t.canGET()) || (isPOST && t.canPOST())));
if (!pass) {
throw new UnauthorizedResponse();
}
}
@ -103,15 +136,52 @@ public class Web {
ctx.json(provider.world(ctx.pathParam("name")));
}
public void chat(Context ctx) {
ctx.json(provider.chat());
}
public void players(Context ctx) {
ctx.json(provider.players());
}
public void plugins(Context ctx) {
ctx.json(provider.plugins());
}
public void bans(Context ctx) {
ctx.json(provider.bans());
}
public void query(Context ctx) {
ctx.json(provider.query());
public void ping(Context ctx) {
ctx.json(provider.ping());
}
public void kick(Context ctx) {
provider.kick(JavalinJson.fromJson(ctx.body(), BanAPI.class));
ctx.status(200);
}
public void ban(Context ctx) {
provider.ban(JavalinJson.fromJson(ctx.body(), BanAPI.class));
ctx.status(200);
}
public void unban(Context ctx) {
provider.unban(JavalinJson.fromJson(ctx.body(), BanAPI.class));
ctx.status(200);
}
public void broadcast(Context ctx) {
provider.broadcast(JavalinJson.fromJson(ctx.body(), BroadcastAPI.class));
ctx.status(200);
}
public void custom(Context ctx) {
CustomAPI capi = JavalinJson.fromJson(ctx.body(), CustomAPI.class);
if (custom.stream().noneMatch(c -> c.equalsIgnoreCase(capi.getCommand()))) {
throw new BadRequestResponse();
}
provider.custom(capi);
ctx.status(200);
}
}

View File

@ -1,12 +1,9 @@
package xyz.etztech.serverapi.web.api;
import com.expediagroup.graphql.annotations.GraphQLDescription;
import com.expediagroup.graphql.annotations.GraphQLDirective;
import com.expediagroup.graphql.annotations.GraphQLName;
import org.bukkit.BanEntry;
import java.util.Date;
@GraphQLName("Ban")
@GraphQLDescription("Ban GraphQL")
public class BanAPI {
@ -16,6 +13,14 @@ public class BanAPI {
private final long created;
private final long expiration;
public BanAPI() {
this.target = "";
this.source = "";
this.reason = "";
this.created = 0;
this.expiration = 0;
}
public BanAPI(String target, String source, String reason, long created, long expiration) {
this.target = target;
this.source = source;

View File

@ -0,0 +1,25 @@
package xyz.etztech.serverapi.web.api;
public class BroadcastAPI {
private final String from;
private final String message;
public BroadcastAPI() {
this.from = "";
this.message = "";
}
public BroadcastAPI(String from, String message) {
this.from = from;
this.message = message;
}
public String getFrom() {
return from;
}
public String getMessage() {
return message;
}
}

View File

@ -0,0 +1,26 @@
package xyz.etztech.serverapi.web.api;
import com.expediagroup.graphql.annotations.GraphQLDescription;
import com.expediagroup.graphql.annotations.GraphQLName;
@GraphQLName("Chat")
@GraphQLDescription("Chat GraphQL")
public class ChatAPI {
private final String message;
private final long timestamp;
public ChatAPI(String message, long timestamp) {
this.message = message;
this.timestamp = timestamp;
}
@GraphQLName("message")
public String getMessage() {
return message;
}
@GraphQLName("timestamp")
public long getTimestamp() {
return timestamp;
}
}

View File

@ -0,0 +1,32 @@
package xyz.etztech.serverapi.web.api;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class CustomAPI {
private final String command;
private final List<String> args;
public CustomAPI() {
this.command = "";
this.args = new ArrayList<>();
}
public CustomAPI(String command, List<String> args) {
this.command = command;
this.args = args;
}
public String getCommand() {
return command;
}
public List<String> getArgs() {
return args;
}
public String build() {
return command + " " + String.join(" ", args);
}
}

View File

@ -5,9 +5,9 @@ import com.expediagroup.graphql.annotations.GraphQLName;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.bukkit.Server;
@GraphQLName("Query")
@GraphQLDescription("Query GraphQL")
public class QueryAPI {
@GraphQLName("Ping")
@GraphQLDescription("Ping GraphQL")
public class PingAPI {
private final String type;
private final String version;
private final String motd;
@ -16,7 +16,7 @@ public class QueryAPI {
@JsonProperty("max_players")
private final int maxPlayers;
public QueryAPI(String type, String version, String motd, int currentPlayers, int maxPlayers) {
public PingAPI(String type, String version, String motd, int currentPlayers, int maxPlayers) {
this.type = type;
this.version = version;
this.motd = motd;
@ -49,8 +49,8 @@ public class QueryAPI {
return maxPlayers;
}
public static QueryAPI fromMinecraft(Server server) {
return new QueryAPI(
public static PingAPI fromMinecraft(Server server) {
return new PingAPI(
server.getName(),
server.getBukkitVersion().split("-")[0], // 1.x.x-R0.1-SNAPSHOT
server.getMotd(),

View File

@ -0,0 +1,54 @@
package xyz.etztech.serverapi.web.api;
import com.expediagroup.graphql.annotations.GraphQLDescription;
import com.expediagroup.graphql.annotations.GraphQLName;
import org.bukkit.plugin.Plugin;
import java.util.ArrayList;
import java.util.List;
@GraphQLName("Plugin")
@GraphQLDescription("Plugin GraphQL")
public class PluginAPI {
private final String name;
private final String version;
private final List<String> authors;
private final String website;
public PluginAPI(String name, String version, List<String> authors, String website) {
this.name = name;
this.version = version;
this.authors = authors != null ? authors : new ArrayList<>();
this.website = website != null ? website : "";
}
@GraphQLName("name")
public String getName() {
return name;
}
@GraphQLName("version")
public String getVersion() {
return version;
}
@GraphQLName("authors")
public List<String> getAuthors() {
return authors;
}
@GraphQLName("website")
public String getWebsite() {
return website;
}
public static PluginAPI fromMinecraft(Plugin plugin) {
return new PluginAPI(
plugin.getName(),
plugin.getDescription().getVersion(),
plugin.getDescription().getAuthors(),
plugin.getDescription().getWebsite()
);
}
}

View File

@ -3,7 +3,6 @@ package xyz.etztech.serverapi.web.api;
import com.expediagroup.graphql.annotations.GraphQLDescription;
import com.expediagroup.graphql.annotations.GraphQLName;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import org.bukkit.World;
@GraphQLName("World")

View File

@ -1,5 +1,18 @@
# The port to run on
port: 8080
# (Optional) password
password: ''
# Number of chat logs to retain
chat: 100
# Authentication
auth:
# Protect GET routes. If false, GET routes are public.
get: true
# Protect POST routes. If false, POST routes are public.
post: true
tokens:
token: access
# Custom commands (POST)
custom:
- say

View File

@ -46,6 +46,21 @@ public class MockProvider implements IProvider {
));
}
@Override
public void kick(BanAPI kick) {}
@Override
public void ban(BanAPI ban) {}
@Override
public void unban(BanAPI ban) {}
@Override
public void broadcast(BroadcastAPI broadcast) {}
@Override
public void custom(CustomAPI custom) {}
@Override
public Set<PlayerAPI> players() {
return new HashSet<>(Arrays.asList(
@ -56,8 +71,27 @@ public class MockProvider implements IProvider {
}
@Override
public QueryAPI query() {
return new QueryAPI("Mock", "0.0.1", "Hello, world!", 0, 100);
public PingAPI ping() {
return new PingAPI("Mock", "0.0.1", "Hello, world!", 0, 100);
}
@Override
public List<PluginAPI> plugins() {
return Arrays.asList(
new PluginAPI("ServerAPI", "0.0.1", Collections.singletonList("Etzelia"), "https://git.etztech.xyz"),
new PluginAPI("dynmap", "0.1.0", null, "https://www.spigotmc.org/resources/dynmap.274/"),
new PluginAPI("CoreProtect", "1.0.0", Collections.singletonList("Intelli"), null)
);
}
@Override
public List<ChatAPI> chat() {
long now = new Date().getTime();
return Arrays.asList(
new ChatAPI("message 1", now-2),
new ChatAPI("message 2", now-1),
new ChatAPI("message 3", now)
);
}
@Override

View File

@ -1,12 +1,29 @@
package xyz.etztech.serverapi;
import xyz.etztech.serverapi.token.Token;
import xyz.etztech.serverapi.token.TokenList;
import xyz.etztech.serverapi.token.TokenScope;
import xyz.etztech.serverapi.web.Web;
import java.util.Arrays;
import java.util.List;
public class ServerRunner {
static TokenList tokens = new TokenList(true, true, Arrays.asList(
new Token("testGET", TokenScope.GET),
new Token("testPOST", TokenScope.POST),
new Token("testAll", TokenScope.ALL),
new Token("testNone", TokenScope.NONE)
));
static List<String> custom = Arrays.asList(
"test",
"say"
);
public static void main(String[] args) {
Web web = new Web(new MockProvider());
web.start(8080, "");
web.start(8080, tokens, custom);
}
}