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 }