1 /* 2 * Copyright (c) 2017-2018 sel-project 3 * 4 * Permission is hereby granted, free of charge, to any person obtaining a copy 5 * of this software and associated documentation files (the "Software"), to deal 6 * in the Software without restriction, including without limitation the rights 7 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 * copies of the Software, and to permit persons to whom the Software is 9 * furnished to do so, subject to the following conditions: 10 * 11 * The above copyright notice and this permission notice shall be included in all 12 * copies or substantial portions of the Software. 13 * 14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 * SOFTWARE. 21 * 22 */ 23 /** 24 * Copyright: 2017-2018 sel-project 25 * License: MIT 26 * Authors: Kripth 27 * Source: $(HTTP github.com/sel-project/selery/source/selery/hub/handler/webview.d, selery/hub/handler/webview.d) 28 */ 29 module selery.hub.handler.webview; 30 31 import core.atomic : atomicOp; 32 import core.thread : Thread; 33 34 import std.bitmanip : nativeToLittleEndian; 35 import std.concurrency : spawn; 36 import std.conv : to; 37 import std.datetime : dur; 38 import std.json; 39 import std.socket; 40 import std.string; 41 import std.system : Endian; 42 import std.uri : decode; 43 import std.zlib : Compress, HeaderFormat; 44 45 import sel.net.http : Status, StatusCodes, Request, Response; 46 import sel.server.query : Query; 47 import sel.server.util; 48 49 import selery.about; 50 import selery.hub.handler.handler : Reloadable; 51 import selery.hub.server : HubServer; 52 import selery.util.diet; 53 import selery.util.thread : SafeThread; 54 import selery.util.util : seconds; 55 56 class WebViewHandler : GenericServer, Reloadable { 57 58 private shared HubServer server; 59 60 private shared WebResource icon; 61 private shared string info; 62 private shared WebResource status; 63 64 private shared string iconRedirect = null; 65 66 private shared string* socialJson; 67 68 private shared string website; 69 70 private shared ulong lastStatusUpdate; 71 72 private shared size_t sessionsCount; 73 74 public shared this(shared HubServer server, shared string* socialJson) { 75 super(server.info); 76 this.server = server; 77 this.socialJson = socialJson; 78 (cast(shared)this).reload(); 79 } 80 81 protected override shared void startImpl(Address address, shared Query query) { 82 Socket socket = new TcpSocket(address.addressFamily); 83 socket.setOption(SocketOptionLevel.SOCKET, SocketOption.REUSEADDR, true); 84 socket.blocking = true; 85 socket.bind(address); 86 socket.listen(8); 87 spawn(&this.acceptClients, cast(shared)socket); 88 } 89 90 private shared void acceptClients(shared Socket _socket) { 91 debug Thread.getThis().name = "web_view_server@" ~ (cast()_socket).localAddress.toString(); 92 Socket socket = cast()_socket; 93 while(true) { 94 Socket client = socket.accept(); 95 client.setOption(SocketOptionLevel.SOCKET, SocketOption.RCVTIMEO, dur!"msecs"(5000)); 96 this.handleClient(client); //TODO use spawn 97 } 98 } 99 100 private shared void handleClient(Socket socket) { 101 new SafeThread(this.server.config.lang, { 102 char[] buffer = new char[1024]; 103 auto recv = socket.receive(buffer); 104 if(recv > 0) { 105 auto response = this.handleConnection(socket, Request.parse(buffer[0..recv].idup)); 106 response.headers["Server"] = Software.display; 107 auto sent = socket.send(response.toString()); 108 } 109 socket.close(); 110 }).start(); 111 } 112 113 public override shared pure nothrow @property @safe @nogc ushort defaultPort() { 114 return ushort(80); 115 } 116 117 public override shared void reload() { 118 // from reload command 119 this.reloadInfoJson(); 120 this.reloadWebResources(); 121 } 122 123 public shared void reloadInfoJson() { 124 const config = this.server.config.hub; 125 JSONValue[string] json, software, protocols; 126 with(Software) { 127 software["name"] = JSONValue(name); 128 software["display"] = JSONValue(display); 129 software["codename"] = JSONValue(["name": JSONValue(codename), "emoji": JSONValue(codenameEmoji)]); 130 software["version"] = JSONValue(["major": JSONValue(major), "minor": JSONValue(minor), "patch": JSONValue(patch)]); 131 if(config.bedrock) protocols["bedrock"] = JSONValue(config.bedrock.protocols); 132 if(config.java) protocols["java"] = JSONValue(config.java.protocols); 133 json["software"] = JSONValue(software); 134 json["protocols"] = JSONValue(protocols); 135 } 136 this.info = JSONValue(json).toString(); 137 } 138 139 public shared void reloadWebResources() { 140 141 const config = this.server.config; 142 143 // icon.png 144 this.icon = WebResource.init; 145 this.iconRedirect = null; 146 with(this.server.icon) { 147 if(url.length) { 148 this.iconRedirect = url; 149 } else if(data.length) { 150 // must be valid if not empty 151 this.icon.uncompressed = cast(string)data; 152 this.icon.compress(); 153 } 154 } 155 156 // status.json 157 this.reloadWebStatus(); 158 159 } 160 161 public shared void reloadWebStatus() { 162 ubyte[] status = nativeToLittleEndian(this.server.onlinePlayers) ~ nativeToLittleEndian(this.server.maxPlayers); 163 { 164 //TODO add an option to disable showing players 165 immutable show_skin = (this.server.onlinePlayers <= 32); 166 foreach(player ; this.server.players) { 167 immutable skin = (show_skin && player.skin !is null) << 15; 168 status ~= nativeToLittleEndian(player.id); 169 status ~= nativeToLittleEndian(to!ushort(player.displayName.length | skin)); 170 status ~= cast(ubyte[])player.displayName; 171 if(skin) status ~= player.skin.face; 172 } 173 } 174 this.status.uncompressed = cast(string)status; 175 if(status.length > 1024) { 176 this.status.compress(); 177 } else { 178 this.status.compressed = null; 179 } 180 this.lastStatusUpdate = seconds; 181 } 182 183 private shared Response handleConnection(Socket socket, Request request) { 184 if(!request.valid || request.path.length == 0 || "host" !in request.headers) return Response.error(StatusCodes.badRequest); 185 if(request.method != "GET") return Response.error(StatusCodes.methodNotAllowed, ["Allow": "GET"]); 186 switch(decode(request.path[1..$])) { 187 case "": 188 const config = this.server.config.hub; 189 immutable host = request.headers["host"]; 190 return Response(StatusCodes.ok, ["Content-Type": "text/html"], compileDietFile!("view.dt", config, host)); 191 case "info.json": 192 return Response(StatusCodes.ok, ["Content-Type": "application/json; charset=utf-8"], this.info); 193 case "social.json": 194 return Response(StatusCodes.ok, ["Content-Type": "application/json; charset=utf-8"], *this.socialJson); 195 case "status": 196 auto time = seconds; 197 if(time - this.lastStatusUpdate > 10) this.reloadWebStatus(); 198 auto response = Response(StatusCodes.ok, ["Content-Type": "application/octet-stream"], this.status.uncompressed); 199 if(this.status.isCompressed) { 200 response = this.returnWebResource(this.status, request, response); 201 } 202 return response; 203 case "icon.png": 204 if(this.iconRedirect !is null) { 205 return Response.redirect(StatusCodes.temporaryRedirect, this.iconRedirect); 206 } else if(this.icon.compressed !is null) { 207 auto response = Response(StatusCodes.ok, ["Content-Type": "image/png"]); 208 return this.returnWebResource(this.icon, request, response); 209 } else { 210 return Response.redirect("//i.imgur.com/uxvZbau.png"); 211 } 212 case "icon": 213 return Response.redirect("/icon.png"); 214 case Software.codenameEmoji: 215 return Response(Status(418, "I'm a " ~ Software.codename.toLower), ["Content-Type": "text/html"], "<head><meta charset='UTF-8'/><style>span{font-size:128px}</style><script>function a(){document.body.innerHTML+='<span>" ~ Software.codenameEmoji ~ "</span>';setTimeout(a,Math.round(Math.random()*2500));}window.onload=a;</script></head>"); 216 case "software": 217 return Response.redirect(Software.website); 218 case "website": 219 if(this.website.length) { 220 return Response.redirect("//" ~ this.website); 221 } else { 222 return Response.error(StatusCodes.notFound); 223 } 224 default: 225 if(request.path.startsWith("/player_") && request.path.endsWith(".json")) { 226 try { 227 auto player = this.server.playerFromId(to!uint(request.path[8..$-5])); 228 if(player !is null) { 229 JSONValue[string] json; 230 json["name"] = player.username; 231 json["display"] = player.displayName; 232 json["version"] = player.game; 233 if(player.skin !is null) json["skin"] = player.skin.faceBase64; 234 if(player.world !is null) json["world"] = ["name": JSONValue(player.world.name), "dimension": JSONValue(player.dimension)]; 235 return Response(StatusCodes.ok, ["Content-Type": "application/json; charset=utf-8"], JSONValue(json).toString()); 236 } 237 } catch(Exception) {} 238 return Response(StatusCodes.notFound, ["Content-Type": "application/json; charset=utf-8"], `{"error":"player not found"}`); 239 } 240 return Response.error(StatusCodes.notFound); 241 } 242 } 243 244 private shared Response returnWebResource(ref shared WebResource resource, Request request, Response response) { 245 auto ae = "accept-encoding" in request.headers; 246 if(ae && ((*ae).indexOf("gzip") >= 0 || *ae == "*")) { 247 response.headers["Content-Encoding"] = "gzip"; 248 response.content = resource.compressed; 249 } else { 250 response.content = resource.uncompressed; 251 } 252 return response; 253 } 254 255 } 256 257 private struct WebResource { 258 259 public string uncompressed; 260 public string compressed = null; 261 262 public shared nothrow @property @safe @nogc bool isCompressed() { 263 return this.compressed !is null; 264 } 265 266 public shared void compress() { 267 Compress c = new Compress(6, HeaderFormat.gzip); 268 auto r = c.compress(this.uncompressed); 269 r ~= c.flush(); 270 this.compressed = cast(string)r; 271 } 272 273 }