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 }