diff --git a/pom.xml b/pom.xml index 7624fee..59d84f9 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.etztech ServerAPI - 0.0.2 + 0.0.3 jar diff --git a/src/main/java/xyz/etztech/serverapi/ServerAPI.java b/src/main/java/xyz/etztech/serverapi/ServerAPI.java index aa0ee19..8b5df44 100644 --- a/src/main/java/xyz/etztech/serverapi/ServerAPI.java +++ b/src/main/java/xyz/etztech/serverapi/ServerAPI.java @@ -3,11 +3,13 @@ package xyz.etztech.serverapi; import org.bukkit.BanEntry; import org.bukkit.BanList; +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.token.TokenList; import xyz.etztech.serverapi.tps.TPS; import xyz.etztech.serverapi.web.IProvider; import xyz.etztech.serverapi.web.Web; @@ -49,7 +51,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") ); } @@ -107,6 +110,44 @@ public class ServerAPI extends JavaPlugin implements IProvider { return bans; } + @Override + public void kick(BanAPI kick) { + Player player = Bukkit.getPlayerExact(kick.getTarget()); + if (player != null) { + 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) { + 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()); diff --git a/src/main/java/xyz/etztech/serverapi/token/Token.java b/src/main/java/xyz/etztech/serverapi/token/Token.java new file mode 100644 index 0000000..ba1bf78 --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/token/Token.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/xyz/etztech/serverapi/token/TokenList.java b/src/main/java/xyz/etztech/serverapi/token/TokenList.java new file mode 100644 index 0000000..80119e4 --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/token/TokenList.java @@ -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 tokens; + + public TokenList(boolean protectGET, boolean protectPOST, List 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 getTokens() { + return tokens; + } +} diff --git a/src/main/java/xyz/etztech/serverapi/token/TokenScope.java b/src/main/java/xyz/etztech/serverapi/token/TokenScope.java new file mode 100644 index 0000000..17b6573 --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/token/TokenScope.java @@ -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; + } + } +} diff --git a/src/main/java/xyz/etztech/serverapi/web/IProvider.java b/src/main/java/xyz/etztech/serverapi/web/IProvider.java index 2362f42..85feea6 100644 --- a/src/main/java/xyz/etztech/serverapi/web/IProvider.java +++ b/src/main/java/xyz/etztech/serverapi/web/IProvider.java @@ -6,6 +6,8 @@ import java.util.List; import java.util.Set; public interface IProvider { + + // GET Set bans(); Set players(); PingAPI ping(); @@ -14,5 +16,13 @@ public interface IProvider { WorldAPI world(String name); List plugins(); + // 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); } diff --git a/src/main/java/xyz/etztech/serverapi/web/Web.java b/src/main/java/xyz/etztech/serverapi/web/Web.java index defd106..f988a30 100644 --- a/src/main/java/xyz/etztech/serverapi/web/Web.java +++ b/src/main/java/xyz/etztech/serverapi/web/Web.java @@ -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 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 custom) { Javalin.log = NOPLogger.NOP_LOGGER; app = Javalin.create(config -> { config.registerPlugin(graphql()); @@ -31,8 +39,14 @@ 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); } @@ -46,6 +60,12 @@ public class Web { app.get("/worlds", this::worlds); app.get("/worlds/:name", this::world); + 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(); Thread.currentThread().setContextClassLoader(ServerAPI.class.getClassLoader()); @@ -77,13 +97,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(); } } @@ -121,4 +150,33 @@ public class Web { 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); + } } diff --git a/src/main/java/xyz/etztech/serverapi/web/api/BanAPI.java b/src/main/java/xyz/etztech/serverapi/web/api/BanAPI.java index 7994f81..c70b2c9 100644 --- a/src/main/java/xyz/etztech/serverapi/web/api/BanAPI.java +++ b/src/main/java/xyz/etztech/serverapi/web/api/BanAPI.java @@ -13,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; diff --git a/src/main/java/xyz/etztech/serverapi/web/api/BroadcastAPI.java b/src/main/java/xyz/etztech/serverapi/web/api/BroadcastAPI.java new file mode 100644 index 0000000..8746d11 --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/web/api/BroadcastAPI.java @@ -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; + } + +} diff --git a/src/main/java/xyz/etztech/serverapi/web/api/CustomAPI.java b/src/main/java/xyz/etztech/serverapi/web/api/CustomAPI.java new file mode 100644 index 0000000..faeaa67 --- /dev/null +++ b/src/main/java/xyz/etztech/serverapi/web/api/CustomAPI.java @@ -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 args; + + public CustomAPI() { + this.command = ""; + this.args = new ArrayList<>(); + } + + public CustomAPI(String command, List args) { + this.command = command; + this.args = args; + } + + public String getCommand() { + return command; + } + + public List getArgs() { + return args; + } + + public String build() { + return command + " " + String.join(" ", args); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 32a3443..311f57d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,5 +1,15 @@ # The port to run on port: 8080 -# (Optional) password -password: '' \ No newline at end of file +# 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 \ No newline at end of file diff --git a/src/test/java/xyz/etztech/serverapi/MockProvider.java b/src/test/java/xyz/etztech/serverapi/MockProvider.java index 2a7ca06..1603777 100644 --- a/src/test/java/xyz/etztech/serverapi/MockProvider.java +++ b/src/test/java/xyz/etztech/serverapi/MockProvider.java @@ -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 players() { return new HashSet<>(Arrays.asList( diff --git a/src/test/java/xyz/etztech/serverapi/ServerRunner.java b/src/test/java/xyz/etztech/serverapi/ServerRunner.java index cc64fe3..ccd67ef 100644 --- a/src/test/java/xyz/etztech/serverapi/ServerRunner.java +++ b/src/test/java/xyz/etztech/serverapi/ServerRunner.java @@ -1,11 +1,30 @@ 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.ArrayList; +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 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); } }