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/webadmin.d, selery/hub/handler/webadmin.d) 28 */ 29 module selery.hub.handler.webadmin; 30 31 import core.atomic : atomicOp; 32 import core.thread : Thread; 33 34 import std.concurrency : spawn; 35 import std.datetime : dur; 36 import std.json; 37 import std.random : uniform; 38 import std.socket : Socket, TcpSocket, Address, SocketOption, SocketOptionLevel; 39 import std.string : startsWith, split, replace; 40 41 import sel.hncom.status : RemoteCommand; 42 import sel.net.http : StatusCodes, Request, Response; 43 import sel.net.stream : TcpStream; 44 import sel.net.websocket : authWebSocketClient, WebSocketServerStream; 45 import sel.server.query : Query; 46 import sel.server.util : GenericServer; 47 48 import selery.about : Software; 49 import selery.hub.player : World, PlayerSession; 50 import selery.hub.server : HubServer; 51 import selery.log : Message; 52 import selery.util.diet; 53 54 class WebAdminHandler : GenericServer { 55 56 private shared HubServer server; 57 58 private shared string style, bg, lock_locked, lock_unlocked; 59 60 private shared string[string] sessions; 61 62 public shared this(shared HubServer server) { 63 super(server.info); 64 this.server = server; 65 // prepare static resources 66 with(server.config.files) { 67 this.style = cast(string)readAsset("web/styles/main.css"); 68 this.bg = cast(string)readAsset("web/res/bg32.png"); 69 this.lock_locked = cast(string)readAsset("web/res/lock_locked.png"); 70 this.lock_unlocked = cast(string)readAsset("web/res/lock_unlocked.png"); 71 } 72 } 73 74 protected override shared void startImpl(Address address, shared Query query) { 75 Socket socket = new TcpSocket(address.addressFamily); 76 socket.setOption(SocketOptionLevel.SOCKET, SocketOption.REUSEADDR, true); 77 socket.blocking = true; 78 socket.bind(address); 79 socket.listen(8); 80 spawn(&this.acceptClients, cast(shared)socket); 81 } 82 83 private shared void acceptClients(shared Socket _socket) { 84 debug Thread.getThis().name = "web_admin_server@" ~ (cast()_socket).localAddress.toString(); 85 Socket socket = cast()_socket; 86 while(true) { 87 Socket client = socket.accept(); 88 client.setOption(SocketOptionLevel.SOCKET, SocketOption.RCVTIMEO, dur!"msecs"(5000)); 89 spawn(&this.handleClient, cast(shared)client); 90 } 91 } 92 93 private shared void handleClient(shared Socket _socket) { 94 Socket socket = cast()_socket; 95 debug Thread.getThis().name = "web_admin_client@" ~ socket.remoteAddress.toString(); 96 char[] buffer = new char[1024]; 97 auto recv = socket.receive(buffer); 98 if(recv > 0) { 99 auto response = this.handle(socket, Request.parse(buffer[0..recv].idup)); 100 response.headers["Server"] = Software.display; 101 socket.send(response.toString()); 102 if(response.status.code == StatusCodes.switchingProtocols.code) { 103 socket.setOption(SocketOptionLevel.SOCKET, SocketOption.RCVTIMEO, dur!"msecs"(0)); 104 socket.blocking = true; 105 // keep connection alive 106 auto client = new WebAdminClient(socket, response.headers["Language"]); 107 this.server.add(client); 108 // send settings 109 client.sendSettings(this.server); 110 // send language files (only for the client's language) 111 client.sendLanguage(this.server.lang.raw[client.language]); 112 // send every world 113 foreach(node ; this.server.nodesList) { 114 foreach(world ; node.worlds) { 115 client.sendAddWorld(world); 116 } 117 } 118 //TODO send players 119 auto address = socket.remoteAddress; 120 while(true) { 121 try { 122 JSONValue[string] json = parseJSON(cast(string)client.receive()).object; 123 switch(json.get("id", JSONValue.init).str) { 124 case "command": 125 this.server.handleCommand(json.get("command", JSONValue.init).str, RemoteCommand.WEB_ADMIN, address, cast(uint)json.get("command_id", JSONValue.init).integer); 126 break; 127 default: 128 break; 129 } 130 } catch(JSONException) { 131 break; 132 } 133 } 134 this.server.remove(client); 135 } 136 } 137 socket.close(); 138 } 139 140 private shared string getClientLanguage(Request request) { 141 auto lang = "accept-language" in request.headers; 142 if(lang) { 143 foreach(l1 ; split(*lang, ";")) { 144 foreach(l2 ; split(l1, ",")) { 145 if(l2.length == 5 || l2.length == 6) { 146 return this.server.config.lang.best(l2.replace("-", "_")); 147 } 148 } 149 } 150 } 151 return this.server.config.lang.language; 152 } 153 154 private shared Response handle(Socket client, Request request) { 155 @property string address(){ return client.remoteAddress.toAddrString(); } 156 if(request.method == Request.GET) { 157 switch(request.path) { 158 case "/style.css": return Response(StatusCodes.ok, ["Content-Type": "text/css"], this.style); 159 case "/res/bg32.png": return Response(StatusCodes.ok, ["Content-Type": "image/png"], this.bg); 160 case "/res/lock_locked.png": return Response(StatusCodes.ok, ["Content-Type": "image/png"], this.lock_locked); 161 case "/res/lock_unlocked.png": return Response(StatusCodes.ok, ["Content-Type": "image/png"], this.lock_unlocked); 162 case "/": 163 bool auth = false; 164 auto cookie = "cookie" in request.headers; 165 if(cookie && startsWith(*cookie, "key=")) { 166 auto ip = (*cookie)[4..$] in this.sessions; 167 if(ip && *ip == address) auth = true; 168 } 169 if("sec-websocket-key" in request.headers) { 170 // new websocket connection 171 if(auth) { 172 auto response = authWebSocketClient(request); 173 if(response.valid) { 174 response.headers["Language"] = getClientLanguage(request); 175 return response; 176 } else { 177 return Response(StatusCodes.badRequest); 178 } 179 } else { 180 return Response(StatusCodes.forbidden); 181 } 182 } else { 183 // send login page or create a session 184 immutable lang = this.getClientLanguage(request); 185 string translate(string text, string[] params...) { 186 return this.server.config.lang.translate(text, params, lang); 187 } 188 if(auth) { 189 // just logged in, needs the admin panel 190 return Response(StatusCodes.ok, compileDietFile!("admin.dt", translate)); 191 } else if(this.server.config.hub.webAdminPassword.length) { 192 // password is required, send login form 193 return Response(StatusCodes.ok, compileDietFile!("login.dt", translate)); 194 } else { 195 // not logged in, but password is not required 196 immutable key = this.addClient(address); 197 if(key.length) return Response(StatusCodes.ok, ["Set-Cookie": "key=" ~ key], compileDietFile!("admin.dt", translate)); 198 else return Response(StatusCodes.ok, "Limit reached"); //TODO 199 } 200 } 201 default: break; 202 } 203 } else if(request.method == Request.POST && request.path == "/login") { 204 // authentication attemp 205 string password; 206 try password = parseJSON(request.data).object.get("password", JSONValue.init).str; 207 catch(JSONException) {} 208 JSONValue[string] result; 209 if(password == this.server.config.hub.webAdminPassword) { //TODO fix sel-net's parser 210 immutable key = this.addClient(address); 211 if(key.length) result["key"] = key; 212 else result["error"] = "limit"; 213 } else { 214 result["error"] = "wrong_password"; 215 } 216 result["success"] = !!("key" in result); 217 return Response(StatusCodes.ok, ["Content-Type": "application/json"], JSONValue(result).toString()); 218 } 219 return Response.error(StatusCodes.notFound); 220 } 221 222 private shared string addClient(string address) { 223 immutable key = randomKey(); 224 this.sessions[key] = address; 225 return key; 226 } 227 228 public override shared pure nothrow @property @safe @nogc ushort defaultPort() { 229 return ushort(19134); 230 } 231 232 } 233 234 class WebAdminClient { 235 236 private static shared uint _id = 0; 237 238 public immutable uint id; 239 240 private WebSocketServerStream stream; 241 242 public string language; 243 244 private immutable string to_string; 245 246 public this(Socket socket, string language) { 247 this.id = atomicOp!"+="(_id, 1); 248 this.stream = new WebSocketServerStream(new TcpStream(socket)); 249 this.language = language; 250 this.to_string = "WebAdmin@" ~ socket.remoteAddress.toString(); 251 } 252 253 public void send(string packet, JSONValue[string] data) { 254 data["packet"] = packet; 255 this.stream.send(JSONValue(data).toString()); 256 } 257 258 public void sendSettings(shared HubServer server) { 259 JSONValue[string] data; 260 data["name"] = server.info.motd.raw; 261 data["max"] = server.maxPlayers; 262 data["favicon"] = server.info.favicon; 263 data["languages"] = server.config.lang.acceptedLanguages; 264 this.send("settings", data); 265 } 266 267 public void sendLanguage(inout string[string] messages) { 268 JSONValue[string] data; 269 foreach(key, value; messages) data[key] = value; 270 this.send("lang", ["data": JSONValue(data)]); 271 } 272 273 public void sendAddWorld(shared World world) { 274 JSONValue[string] data; 275 data["id"] = world.id; 276 data["name"] = world.name; 277 data["dimension"] = world.dimension; 278 if(world.parent !is null) data["parent_id"] = world.parent.id; 279 this.send("add_world", data); 280 } 281 282 public void sendRemoveWorld(shared World world) { 283 this.send("remove_world", ["id": JSONValue(world.id)]); 284 } 285 286 public void sendAddPlayer(shared PlayerSession player) {} 287 288 public void sendRemovePlayer(shared PlayerSession player) {} 289 290 public void sendLog(Message[] messages, int commandId, int worldId) { 291 JSONValue[] log; 292 string next; 293 void addText() { 294 log ~= JSONValue(["text": next]); 295 next.length = 0; 296 } 297 foreach(message ; messages) { 298 if(message.type == Message.FORMAT) next ~= message.format; 299 else if(message.type == Message.TEXT) next ~= message.text; 300 else { 301 if(next.length) addText(); 302 log ~= JSONValue(["translation": JSONValue(message.translation.translatable.default_), "with": JSONValue(message.translation.parameters)]); 303 } 304 } 305 if(next.length) addText(); 306 this.send("log", ["log": JSONValue(log), "command_id": JSONValue(commandId), "world_id": JSONValue(worldId)]); 307 } 308 309 public ubyte[] receive() { 310 return this.stream.receive(); 311 } 312 313 public override string toString() { 314 return this.to_string; 315 } 316 317 } 318 319 private enum keys = "abcdefghijklmonpqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+$!"; 320 321 private @property string randomKey() { 322 char[] key = new char[24]; 323 foreach(ref char c ; key) { 324 c = keys[uniform!"[)"(0, keys.length)]; 325 } 326 return key.idup; 327 }