diff --git a/README.md b/README.md index 72ddf08..db86114 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The settings are as follows: Configures the HTTP and WebSocket server: * `port` (4081): TCP port for the HTTP server to listen on +* `publicURL` (http://localhost:4081/) - URL for publicly accessing this server, sent to clients when running the `/websandbox auth` command * `takeover` (false): advanced experimental option to reuse the server port from Bukkit (ignoring `port`) before startup, allowing this plugin to be used on hosts where only one port is allowed * `unbind_method` ('console.getServerConnection.b'): if `takeover` enabled, this method is called on `Bukkit.getServer()`, may need to change depending on your Bukkit server implementation @@ -54,6 +55,8 @@ Configures what part of your world to expose: * `z_center` (0): " ", Z coordinate * If x/y/z center are all 0, then the world's spawn location is used instead * `radius` (16): range out of the center to expose in each direction (cube), setting too high will slow down web client loading +* `clickable_links` (true): send clickable links in chat commands from `/websandbox auth` if true, or as plain text if false +* `clickable_links_tellraw` (false): use the `/tellraw` command to send richly formatted messages if true, or use the TextComponents API if false, change this if you get a formatting error with `/websandbox auth` * `entity` ("Sheep"): name of entity class to spawn on server for web users, set to "" to disable * `entity_custom_names` (true): add web player names to the spawned entity's nametag if true * `entity_disable_gravity` (true): disable gravity for the spawned entities if true @@ -65,6 +68,7 @@ Configures what part of your world to expose: Configures the NetCraft web client: * `y_offset` (20): height to shift the web client blocks upwards, to distinguish from the pre-generated landscape +* `allow_anonymous` (true): allow web users to connect without logging in, otherwise a player must first run `/websandbox auth` and click the link * `allow_break_place_blocks` (true): allow web users to break/place blocks, set to false for view-only (see also `allow_signs`) * `unbreakable_blocks` (`BEDROCK`): list of block types to deny the client from breaking or placing * `allow_signs` (true): allow web users to place signs (by typing backquote followed by the text) @@ -88,10 +92,13 @@ texture pack compatibility, see [NetCraft#textures](https://github.com/satoshinm ## Commands -* `/websandbox`: show help +* `/websandbox` or `/websandbox help`: show help * `/websandbox list [verbose]`: list all web users connected * `/websandbox tp []`: teleport to given web username, or web spawn location * `/websandbox kick `: disconnect given web username +* `/websandbox auth []`: generates an a web link to allow the player to authenticate over the web as themselves instead of anonymously + +All commands except help and auth require op, or a `websandbox.command.` permission node. ## Compatibility diff --git a/src/main/java/io/github/satoshinm/WebSandboxMC/Settings.java b/src/main/java/io/github/satoshinm/WebSandboxMC/Settings.java index e2f83bb..3b94c6b 100644 --- a/src/main/java/io/github/satoshinm/WebSandboxMC/Settings.java +++ b/src/main/java/io/github/satoshinm/WebSandboxMC/Settings.java @@ -10,6 +10,7 @@ abstract public class Settings { // User configurable settings public int httpPort = 4081; + public String publicURL = "http://localhost:" + httpPort + "/"; public boolean takeover = false; public String unbindMethod = "console.getServerConnection.b"; @@ -31,9 +32,13 @@ abstract public class Settings { // of this radius, +/- public int radius = 16; + public boolean clickableLinks = true; + public boolean clickableLinksTellraw = false; + // raised this amount in the web world, so it is clearly distinguished from the client-generated terrain public int y_offset = 20; + public boolean allowAnonymous = true; public boolean allowBreakPlaceBlocks = true; public List unbreakableBlocks = new ArrayList(); public boolean allowSigns = true; diff --git a/src/main/java/io/github/satoshinm/WebSandboxMC/bridge/WebPlayerBridge.java b/src/main/java/io/github/satoshinm/WebSandboxMC/bridge/WebPlayerBridge.java index 7b1e949..435060c 100644 --- a/src/main/java/io/github/satoshinm/WebSandboxMC/bridge/WebPlayerBridge.java +++ b/src/main/java/io/github/satoshinm/WebSandboxMC/bridge/WebPlayerBridge.java @@ -3,13 +3,24 @@ import io.github.satoshinm.WebSandboxMC.Settings; import io.github.satoshinm.WebSandboxMC.ws.WebSocketServerThread; import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelId; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; +import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; import org.bukkit.entity.Entity; import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; import org.bukkit.entity.Sheep; import org.bukkit.event.entity.EntityDamageEvent; +import org.json.simple.JSONObject; +import java.math.BigInteger; +import java.security.SecureRandom; import java.util.HashMap; import java.util.Map; import java.util.logging.Level; @@ -27,6 +38,12 @@ public class WebPlayerBridge { public Map entityId2Username; public Map name2channel; + private Map playerAuthKeys = new HashMap(); + private boolean clickableLinks; + private boolean clickableLinksTellraw; + private String publicURL; + + private boolean allowAnonymous; private boolean setCustomNames; private boolean disableGravity; private boolean disableAI; @@ -58,6 +75,11 @@ public WebPlayerBridge(WebSocketServerThread webSocketServerThread, Settings set this.constrainToSandbox = settings.entityMoveSandbox; this.dieDisconnect = settings.entityDieDisconnect; + this.clickableLinks = settings.clickableLinks; + this.clickableLinksTellraw = settings.clickableLinksTellraw; + this.publicURL = settings.publicURL; + + this.allowAnonymous = settings.allowAnonymous; this.lastPlayerID = 0; this.channelId2name = new HashMap(); this.channelId2Entity = new HashMap(); @@ -65,9 +87,24 @@ public WebPlayerBridge(WebSocketServerThread webSocketServerThread, Settings set this.name2channel = new HashMap(); } - public String newPlayer(final Channel channel) { - int theirID = ++this.lastPlayerID; - final String theirName = "webguest" + theirID; + public boolean newPlayer(final Channel channel, String proposedUsername, String token) { + String theirName; + if (validateClientAuthKey(proposedUsername, token)) { + theirName = proposedUsername; + // TODO: more features when logging in as an authenticated user: move to their last spawn? + } else { + if (!proposedUsername.equals("")) { // blank = anonymous + webSocketServerThread.sendLine(channel, "T,Failed to login as "+proposedUsername); + } + + if (!allowAnonymous) { + webSocketServerThread.sendLine(channel,"T,This server requires authentication."); + return false; + } + + int theirID = ++this.lastPlayerID; + theirName = "webguest" + theirID; + } this.channelId2name.put(channel.id(), theirName); this.name2channel.put(theirName, channel); @@ -83,7 +120,7 @@ public String newPlayer(final Channel channel) { entity.setCustomNameVisible(true); } if (disableGravity) { - entity.setGravity(false); // allow flying TODO: this doesn't seem to work on Glowstone? drops like a rock. update: known bug: https://github.com/GlowstoneMC/Glowstone/issues/454 + entity.setGravity(false); // allow flying } if (disableAI) { if (entity instanceof LivingEntity) { @@ -101,8 +138,7 @@ public String newPlayer(final Channel channel) { // TODO: should this go to Bukkit chat, too/instead? make configurable? webSocketServerThread.broadcastLine("T," + theirName + " has joined."); - - return theirName; + return true; } public void clientMoved(final Channel channel, final double x, final double y, final double z, final double rx, final double ry) { @@ -193,4 +229,63 @@ public void notifySheared(String username, String playerName) { webSocketServerThread.sendLine(channel, "T,You were sheared by " + playerName); } } + + private final SecureRandom random = new SecureRandom(); + + public void newClientAuthKey(String username, CommandSender sender) { + String token = new BigInteger(130, random).toString(32); + + playerAuthKeys.put(username, token); + // TODO: persist to disk + + + String url = publicURL + "#++" + username + "+" + token; + + if (clickableLinks && sender instanceof Player) { + Player player = (Player) sender; + + String linkText = "Click here to login"; + String hoverText = "Login to the web sandbox as " + player.getName(); + + // There are two strategies since TextComponents fails with on Glowstone with an error: + // java.lang.UnsupportedOperationException: Not supported yet. + // at org.bukkit.entity.Player$Spigot.sendMessage(Player.java:1734) + // see https://github.com/GlowstoneMC/Glowkit-Legacy/pull/8 + if (clickableLinksTellraw) { + JSONObject json = new JSONObject(); + json.put("text", linkText); + json.put("bold", true); + + JSONObject clickEventJson = new JSONObject(); + clickEventJson.put("action", "open_url"); + clickEventJson.put("value", url); + json.put("clickEvent", clickEventJson); + + JSONObject hoverEventJson = new JSONObject(); + hoverEventJson.put("action", "show_text"); + JSONObject hoverTextObject = new JSONObject(); + hoverTextObject.put("text", hoverText); + hoverEventJson.put("value", hoverTextObject); + json.put("hoverEvent", hoverEventJson); + + Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), "tellraw " + player.getName() + " " + json.toJSONString()); + } else { + TextComponent message = new TextComponent(linkText); + message.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url)); + message.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new TextComponent[] { new TextComponent(hoverText) })); + message.setBold(true); + + player.spigot().sendMessage(message); + } + } else { + sender.sendMessage("Visit this URL to login: " + url); + } + } + + private boolean validateClientAuthKey(String username, String token) { + String expected = playerAuthKeys.get(username); + if (expected == null) return false; + return expected.equals(token); + // TODO: load from disk + } } diff --git a/src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/SettingsBukkit.java b/src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/SettingsBukkit.java index 95c6e1f..ac31af8 100644 --- a/src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/SettingsBukkit.java +++ b/src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/SettingsBukkit.java @@ -21,6 +21,7 @@ public SettingsBukkit(Plugin plugin) { config.options().copyDefaults(true); config.addDefault("http.port", this.httpPort); + config.addDefault("http.publicURL", this.publicURL); config.addDefault("http.takeover", this.takeover); config.addDefault("http.unbind_method", this.unbindMethod); @@ -37,8 +38,11 @@ public SettingsBukkit(Plugin plugin) { config.addDefault("mc.y_center", this.y_center); config.addDefault("mc.z_center", this.z_center); config.addDefault("mc.radius", this.radius); + config.addDefault("mc.clickable_links", this.clickableLinks); + config.addDefault("mc.clickable_links_tellraw", this.clickableLinksTellraw); config.addDefault("nc.y_offset", this.y_offset); + config.addDefault("nc.allow_anonymous", this.allowAnonymous); config.addDefault("nc.allow_break_place_blocks", this.allowBreakPlaceBlocks); this.unbreakableBlocks.add("BEDROCK"); config.addDefault("nc.unbreakable_blocks", this.unbreakableBlocks); @@ -51,6 +55,7 @@ public SettingsBukkit(Plugin plugin) { config.addDefault("nc.warn_missing_blocks_to_web", this.warnMissing); this.httpPort = plugin.getConfig().getInt("http.port"); + this.publicURL = plugin.getConfig().getString("http.publicURL"); this.takeover = plugin.getConfig().getBoolean("http.takeover"); this.unbindMethod = plugin.getConfig().getString("http.unbind_method"); @@ -70,8 +75,12 @@ public SettingsBukkit(Plugin plugin) { this.z_center = plugin.getConfig().getInt("mc.z_center"); this.radius = plugin.getConfig().getInt("mc.radius"); + this.clickableLinks = plugin.getConfig().getBoolean("mc.clickable_links"); + this.clickableLinksTellraw = plugin.getConfig().getBoolean("mc.clickable_links_tellraw"); + this.y_offset = plugin.getConfig().getInt("nc.y_offset"); + this.allowAnonymous = plugin.getConfig().getBoolean("nc.allow_anonymous"); this.allowBreakPlaceBlocks = plugin.getConfig().getBoolean("nc.allow_break_place_blocks"); this.unbreakableBlocks = plugin.getConfig().getStringList("nc.unbreakable_blocks"); this.allowSigns = plugin.getConfig().getBoolean("nc.allow_signs"); diff --git a/src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/WsCommand.java b/src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/WsCommand.java index df9334a..297a14c 100644 --- a/src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/WsCommand.java +++ b/src/main/java/io/github/satoshinm/WebSandboxMC/bukkit/WsCommand.java @@ -2,10 +2,13 @@ import io.github.satoshinm.WebSandboxMC.ws.WebSocketServerThread; import io.netty.channel.Channel; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.TextComponent; import org.bukkit.Location; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -26,8 +29,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (sender instanceof Player) { Player player = (Player) sender; if (!usePermissions) { - if (!player.isOp()) { - sender.sendMessage("/websandbox requires op"); + if (!player.isOp() && !subcommand.equals("auth") && !subcommand.equals("help")) { + sender.sendMessage("This /websandbox subcommand requires op"); return true; } } else { @@ -116,14 +119,33 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } - sender.sendMessage("Kicking web client "+name); - webSocketServerThread.sendLine(channel,"T,You were kicked by "+sender.getName()); + sender.sendMessage("Kicking web client " + name); + webSocketServerThread.sendLine(channel, "T,You were kicked by " + sender.getName()); webSocketServerThread.webPlayerBridge.clientDisconnected(channel); + return true; + } else if (subcommand.equals("auth")) { + // TODO: non-ops should be able to run this command by default + String name; + + if (!(sender instanceof Player)) { + if (split.length < 2) { + sender.sendMessage("Usage: /websandbox auth "); + return true; + } + name = split[1]; + } else { + Player player = (Player) sender; + name = player.getName(); + } + + webSocketServerThread.webPlayerBridge.newClientAuthKey(name, sender); + return true; } else { // help sender.sendMessage("/websandbox list [verbose] -- list all web users connected"); sender.sendMessage("/websandbox tp [] -- teleport to given web username, or web spawn location"); sender.sendMessage("/websandbox kick -- disconnect given web username"); + sender.sendMessage("/websandbox auth [] -- get authentication token to login non-anonymously"); // TODO: reload, reconfig commands } return false; diff --git a/src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketFrameHandler.java b/src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketFrameHandler.java index ccd8a18..b685307 100644 --- a/src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketFrameHandler.java +++ b/src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketFrameHandler.java @@ -31,25 +31,6 @@ public WebSocketFrameHandler(WebSocketServerThread webSocketServerThread) { this.webSocketServerThread = webSocketServerThread; } - @Override - @SuppressWarnings("deprecation") // TODO: why is HANDSHAKE_COMPLETE deprecated and what is the replacement? - public void userEventTriggered(final ChannelHandlerContext ctx, Object evt) throws Exception { - webSocketServerThread.log(Level.FINEST, "userEventTriggered: "+evt); - if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) { - // "The Handshake was complete successful and so the channel was upgraded to websockets" - - // Since we're in a callback we cannot call any Bukkit API safely here, see: - // http://bukkit.gamepedia.com/Scheduler_Programming#Tips_for_thread_safety - // " Warning: Asynchronous tasks should never access any API in Bukkit" - webSocketServerThread.scheduleSyncTask(new Runnable() { - @Override - public void run() { - webSocketServerThread.handleNewClient(ctx); - } - }); - } - } - @Override public void channelRead0(final ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception { webSocketServerThread.log(Level.FINEST, "channel read, frame="+frame); diff --git a/src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketServerThread.java b/src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketServerThread.java index 1d4d3cb..a918a9d 100644 --- a/src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketServerThread.java +++ b/src/main/java/io/github/satoshinm/WebSandboxMC/ws/WebSocketServerThread.java @@ -116,7 +116,8 @@ public void run() { Channel ch = b.bind(PORT).sync().channel(); log(Level.INFO, "Open your web browser and navigate to " + - (SSL ? "https" : "http") + "://127.0.0.1:" + PORT + "/"); + (SSL ? "https" : "http") + "://127.0.0.1:" + PORT + "/" + + " or " + settings.publicURL); ch.closeFuture().sync(); } catch (InterruptedException ex) { @@ -148,14 +149,16 @@ public void broadcastLineExcept(ChannelId excludeChannelId, String message) { } } - // Handle a command from the client - public void handleNewClient(ChannelHandlerContext ctx) { + public void handleNewClient(ChannelHandlerContext ctx, String username, String token) { Channel channel = ctx.channel(); + if (!webPlayerBridge.newPlayer(channel, username, token)) { + channel.close(); + return; + } + allUsersGroup.add(channel); - /*String theirName = */webPlayerBridge.newPlayer(channel); - // TODO: join newPlayer _after_ sending world? since then they are really "in" the world, before, in-progress /* Send initial server messages on client connect here, example from Python server for comparison: @@ -172,9 +175,27 @@ public void handleNewClient(ChannelHandlerContext ctx) { blockBridge.sendWorld(channel); playersBridge.sendPlayers(channel); } - // TODO: cleanup clients when they disconnect + // Handle a command from the client public void handle(String string, ChannelHandlerContext ctx) { + if (string.startsWith("A,")) { + String[] array = string.trim().split(","); + String username = ""; + String token = ""; + if (array.length == 3) { + username = array[1]; + token = array[2]; + } + handleNewClient(ctx, username, token); + return; + } + + if (!allUsersGroup.contains(ctx.channel())) { + // Commands below this point require a successfully logged-in user + this.log(Level.FINEST, "Client tried to send command when not authenticated: "+string+" from "+ctx); + return; + } + if (string.startsWith("B,")) { this.log(Level.FINEST, "client block update: "+string); String[] array = string.trim().split(","); diff --git a/src/main/resources-filtered/plugin.yml b/src/main/resources-filtered/plugin.yml index e1ed2f0..c3002fe 100644 --- a/src/main/resources-filtered/plugin.yml +++ b/src/main/resources-filtered/plugin.yml @@ -4,4 +4,4 @@ version: ${version} commands: websandbox: description: Controls the WebSandbox WebGL HTML5 web interface. - usage: "Usage: /websandbox list|tp|kick" + usage: "Usage: /websandbox list|tp|kick|auth"