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 module config;
24 
25 import std.ascii : newline;
26 import std.conv : to, ConvException;
27 import std.file : exists, read, write, remove, tempDir, mkdirRecurse;
28 import std.json : JSONValue;
29 import std.path : dirSeparator, buildNormalizedPath;
30 import std.socket : Address;
31 import std.string : replace, split, join, toLower, toUpper, startsWith, endsWith;
32 import std.traits : isArray, isAssociativeArray, isIntegral, isFloatingPoint;
33 import std.typetuple : TypeTuple;
34 import std.uuid : UUID, parseUUID;
35 import std.zip : ZipArchive;
36 
37 import selery.about;
38 import selery.config : Config, Files;
39 import selery.lang : LanguageManager;
40 
41 import toml;
42 import toml.json;
43 
44 enum bool portable = __traits(compiles, import("portable.zip"));
45 
46 enum ConfigType : string {
47 
48 	default_ = "default",
49 	hub = "hub",
50 	node = "node"
51 
52 }
53 
54 mixin({
55 	string[] commands;
56 	foreach(member ; __traits(allMembers, Config.Node)) {
57 		static if(member.endsWith("Command")) commands ~= (`"` ~ member[0..$-7] ~ `"`);
58 	}
59 	return "alias Commands = TypeTuple!(" ~ commands.join(",") ~ ");";
60 }());
61 
62 auto loadConfig(ConfigType type, ref string[] args) {
63 
64 	immutable filename = (){
65 		final switch(type) with(ConfigType) {
66 			case default_: return "selery.toml";
67 			case hub: return "selery.hub.toml";
68 			case node: return "selery.node.toml";
69 		}
70 	}();
71 	
72 	immutable isHub = type == ConfigType.default_ || type == ConfigType.hub;
73 	immutable isNode = type == ConfigType.default_ || type == ConfigType.node;
74 	
75 	bool hasArg(string a) {
76 		foreach(i, arg; args) {
77 			if(arg == a) {
78 				args = args[0..i] ~ args[i+1..$];
79 				return true;
80 			}
81 		}
82 		return false;
83 	}
84 
85 	auto config = new class Config {
86 	
87 		private string language;
88 	
89 		public override void load() {
90 		
91 			version(Windows) {
92 				import std.utf : toUTF8;
93 				import std.string : fromStringz;
94 				import core.sys.windows.winnls;
95 				wchar[] lang = new wchar[3];
96 				wchar[] country = new wchar[3];
97 				GetLocaleInfo(GetUserDefaultUILanguage(), LOCALE_SISO639LANGNAME, lang.ptr, 3);
98 				GetLocaleInfo(GetUserDefaultUILanguage(), LOCALE_SISO3166CTRYNAME, country.ptr, 3);
99 				this.language = fromStringz(toUTF8(lang).ptr) ~ "_" ~ fromStringz(toUTF8(country).ptr);
100 			} else {
101 				import std.process : environment;
102 				this.language = environment.get("LANGUAGE", environment.get("LANG", "en_US"));
103 			}
104 		
105 			this.reload();
106 		
107 			immutable temp = buildNormalizedPath(tempDir() ~ dirSeparator ~ "selery" ~ dirSeparator ~ this.uuid.toString().toUpper());
108 			mkdirRecurse(temp);
109 			
110 			static if(portable) {
111 				
112 				this.files = new CompressedFiles(new ZipArchive(cast(void[])import("portable.zip")), temp);
113 				
114 			} else {
115 	
116 				this.files = new Files("assets", temp);
117 				
118 			}
119 			
120 			this.lang = new LanguageManager(this.files, this.language);
121 			this.lang.load();
122 		
123 		}
124 	
125 		public override void reload() {
126 		
127 			TOMLDocument document;
128 	
129 			if(exists(filename)) document = parseTOML(cast(string)read(filename));
130 				
131 			T get(T)(TOMLValue target) {
132 				static if(is(T == string)) {
133 					return target.str;
134 				} else static if(isArray!T) {
135 					T ret;
136 					foreach(value ; target.array) {
137 						ret ~= get!(typeof(ret[0]))(value);
138 					}
139 					return ret;
140 				} else static if(isAssociativeArray!T) {
141 					T ret;
142 					foreach(key, value; target.table) {
143 						ret[key] = get!(typeof(ret[""]))(value);
144 					}
145 					return ret;
146 				} else static if(is(T == bool)) {
147 					return target.boolean;
148 				} else static if(isFloatingPoint!T) {
149 					return cast(T)target.floating;
150 				} else static if(isIntegral!T) {
151 					return cast(T)target.integer;
152 				} else static if(is(T == UUID)) {
153 					return parseUUID(get!string(target));
154 				} else static if(is(T == JSONValue)) {
155 					return toJSON(target); //TODO handle conversion errors
156 				} else static if(is(T == Config.Hub.Address)) {
157 					return convertAddress(target.str);
158 				} else {
159 					static assert(0);
160 				}
161 			}
162 			
163 			TOMLValue getValue(TOMLValue[string] table, const(string)[] keys) {
164 				auto value = keys[0] in table;
165 				if(value) {
166 					if(keys.length == 1) return *value;
167 					else return getValue((*value).table, keys[1..$]); // throws exception if not a table
168 				} else {
169 					throw new TOMLException(keys[0] ~ " not in table");
170 				}
171 			}
172 			
173 			void set(T)(ref T value, const(string)[] keys...) {
174 				static if(is(T == string) || isIntegral!T || isFloatingPoint!T || is(T == bool) || isArray!T) {
175 					// override using --key=value
176 					immutable option = "--" ~ keys.join("-");
177 					foreach(i, arg; args) {
178 						if(arg.startsWith(option ~ "=")) {
179 							args = args[0..i] ~ args[i+1..$];
180 							try {
181 								immutable data = arg[option.length+1..$];
182 								static if(isArray!T && !is(T == string)) {
183 									T _value;
184 									alias A = typeof(_value[0]);
185 									foreach(s_data ; split(data, ",")) {
186 										static if(is(A == Config.Hub.Address)) _value ~= convertAddress(s_data);
187 										else _value ~= to!A(s_data);
188 									}
189 									value = _value;
190 								} else {
191 									value = to!T(data);
192 								}
193 							} catch(ConvException) {}
194 							return;
195 						}
196 					}
197 				}
198 				try {
199 					value = get!T(getValue(document.table, keys));
200 				} catch(TOMLException) {}
201 			}
202 			
203 			void setProtocols(ref uint[] value, uint[] all, uint[] latest, const(string)[] keys...) {
204 				string s;
205 				set(s, keys);
206 				if(s == "all" || s == "*") value = all;
207 				else if(s == "latest") value = latest;
208 				else set(value, keys);
209 			}
210 
211 			set(this.uuid, "uuid");
212 			set(this.language, "language");
213 			
214 			if(isHub) with(this.hub = new Config.Hub()) {
215 			
216 				// override default
217 				webAdmin = type == ConfigType.default_;
218 				webAdminOpen = portable;
219 			
220 				set(displayName, "display-name");
221 				set(edu, "edu");
222 				set(bedrock.enabled, "bedrock", "enabled");
223 				set(bedrock.motd, "bedrock", "motd");
224 				set(bedrock.addresses, "bedrock", "addresses");
225 				setProtocols(bedrock.protocols, supportedBedrockProtocols, latestBedrockProtocols, "bedrock", "accepted-protocols");
226 				set(allowVanillaPlayers, "bedrock", "allow-vanilla-players");
227 				set(java.enabled, "java", "enabled");
228 				set(java.motd, "java", "motd");
229 				set(java.addresses, "java", "addresses");
230 				setProtocols(java.protocols, supportedJavaProtocols, latestJavaProtocols, "java", "accepted-protocols");
231 				set(query, "query-enabled");
232 				set(serverIp, "server-ip");
233 				set(favicon, "favicon");
234 				set(rcon, "rcon", "enabled");
235 				set(rconPassword, "rcon", "password");
236 				set(rconAddresses, "rcon", "addresses");
237 				set(webView, "web-view", "enabled");
238 				set(webViewAddresses, "web-view", "addresses");
239 				set(webAdmin, "web-admin", "enabled");
240 				set(webAdminOpen, "web-admin", "open-on-startup");
241 				set(webAdminAddresses, "web-admin", "addresses");
242 				set(webAdminPassword, "web-admin", "password");
243 				set(webAdminMaxClients, "web-admin", "max-clients");
244 				set(acceptedNodes, "hncom", "accepted-addresses");
245 				set(hncomPassword, "hncom", "password");
246 				set(maxNodes, "hncom", "node-limit");
247 				set(hncomPort, "hncom", "port");
248 				set(social, "social");
249 				
250 				// unlimited nodes
251 				string unlimited;
252 				set(unlimited, "hncom", "node-limit");
253 				if(unlimited.toLower() == "unlimited") maxNodes = 0;
254 				
255 			}
256 			
257 			if(isNode) with(this.node = new Config.Node()) {
258 			
259 				// override default
260 				transferCommand = type != ConfigType.default_;
261 			
262 				set(name, "hub", "name");
263 				set(password, "hub", "password");
264 				set(ip, "hub", "ip");
265 				set(port, "hub", "port");
266 				set(main, "hub", "main");
267 				set(bedrock.enabled, "bedrock", "enabled");
268 				setProtocols(bedrock.protocols, supportedBedrockProtocols, latestBedrockProtocols, "bedrock", "accepted-protocols");
269 				set(java.enabled, "java", "enabled");
270 				setProtocols(java.protocols, supportedJavaProtocols, latestJavaProtocols, "java", "accepted-protocols");
271 				set(maxPlayers, "max-players");
272 				set(gamemode, "world", "gamemode");
273 				set(difficulty, "world", "difficulty");
274 				set(depleteHunger, "world", "deplete-hunger");
275 				set(doDaylightCycle, "world", "do-daylight-cycle");
276 				set(doEntityDrops, "world", "do-entity-drops");
277 				set(doFireTick, "world", "do-fire-tick");
278 				set(doScheduledTicks, "world", "do-scheduled-ticks");
279 				set(doWeatherCycle, "world", "do-weather-cycle");
280 				set(naturalRegeneration, "natural-regeneration");
281 				set(pvp, "world", "pvp");
282 				set(randomTickSpeed, "world", "random-tick-speed");
283 				set(viewDistance, "view-distance");
284 				
285 				// commands
286 				foreach(command ; Commands) {
287 					set(mixin(command ~ "Command"), "command", command);
288 				}
289 				
290 				// unlimited players
291 				string unlimited;
292 				set(unlimited, "max-players");
293 				if(unlimited.toLower() == "unlimited") maxPlayers = 0;
294 				
295 			}
296 			
297 			if(!exists(filename)) this.save();
298 		
299 		}
300 		
301 		public override void save() {
302 		
303 			string serializeProtocols(uint[] protocols, uint[] all, uint[] latest) {
304 				if(protocols == latest) return `"latest"`;
305 				else if(protocols == all) return `"all"`;
306 				else return to!string(protocols);
307 			}
308 		
309 			// is this needed?
310 			if(this.hub is null) this.hub = new Config.Hub();
311 			if(this.node is null) this.node = new Config.Node();
312 		
313 			string file = "# " ~ Software.name ~ " " ~ Software.fullVersion ~ " configuration file" ~ newline ~ newline;
314 			
315 			file ~= "uuid = \"" ~ this.uuid.toString().toUpper() ~ "\"" ~ newline;
316 			if(isHub) file ~= "display-name = \"" ~ this.hub.displayName ~ "\"" ~ newline;
317 			if(isNode) file ~= "max-players = " ~ (this.node.maxPlayers == 0 ? "\"unlimited\"" : to!string(this.node.maxPlayers)) ~ newline;
318 			file ~= "language = \"" ~ this.language ~ "\"" ~ newline;
319 			if(isHub) file ~= "server-ip = \"" ~ this.hub.serverIp ~ "\"" ~ newline;
320 			if(isHub) file ~= "query-enabled = " ~ to!string(this.hub.query) ~ newline;
321 			if(isHub && !this.hub.edu) file ~= "favicon = \"" ~ this.hub.favicon ~ "\"" ~ newline;
322 			if(isHub) file ~= "social = {}" ~ newline; //TODO
323 			if(isHub) with(this.hub.bedrock) {
324 				file ~= newline ~ "[bedrock]" ~ newline;
325 				file ~= "enabled = " ~ to!string(enabled) ~ newline;
326 				file ~= "motd = \"" ~ motd ~ "\"" ~ newline;
327 				file ~= "online-mode = false" ~ newline;
328 				file ~= "addresses = " ~ addressString(addresses) ~ newline;
329 				file ~= "accepted-protocols = " ~ serializeProtocols(protocols, supportedBedrockProtocols, latestBedrockProtocols) ~ newline;
330 				if(this.hub.edu) file ~= newline ~ "allow-vanilla-players = " ~ to!string(this.hub.allowVanillaPlayers);
331 			}
332 			if(isHub && !this.hub.edu) with(this.hub.java) {
333 				file ~= newline ~ "[java]" ~ newline;
334 				file ~= "enabled = " ~ to!string(enabled) ~ newline;
335 				file ~= "motd = \"" ~ motd ~ "\"" ~ newline;
336 				file ~= "online-mode = false" ~ newline;
337 				file ~= "addresses = " ~ addressString(addresses) ~ newline;
338 				file ~= "accepted-protocols = " ~ serializeProtocols(protocols, supportedJavaProtocols, latestJavaProtocols) ~ newline;
339 			}
340 			if(type == ConfigType.node) with(this.node) {
341 				file ~= newline ~ "[hub]" ~ newline;
342 				file ~= "name = \"" ~ name ~ "\"" ~ newline;
343 				file ~= "password = \"" ~ password ~ "\"" ~ newline;
344 				file ~= "ip = \"" ~ ip ~ "\"" ~ newline;
345 				file ~= "port = " ~ to!string(port) ~ newline;
346 				file ~= "main = " ~ to!string(main) ~ newline;
347 			}
348 			if(type == ConfigType.node) with(this.node.bedrock) {
349 				file ~= newline ~ "[bedrock]" ~ newline;
350 				file ~= "enabled = " ~ to!string(enabled) ~ newline;
351 				file ~= "accepted-protocols = " ~ serializeProtocols(protocols, supportedBedrockProtocols, latestBedrockProtocols) ~ newline;
352 			}
353 			if(type == ConfigType.node) with(this.node.java) {
354 				file ~= newline ~ "[java]" ~ newline;
355 				file ~= "enabled = " ~ to!string(enabled) ~ newline;
356 				file ~= "accepted-protocols = " ~ serializeProtocols(protocols, supportedJavaProtocols, latestJavaProtocols) ~ newline;
357 			}
358 			if(isNode) with(this.node) {
359 				file ~= newline ~ "[world]" ~ newline;
360 				file ~= "gamemode = " ~ to!string(gamemode) ~ newline;
361 				file ~= "difficulty = " ~ to!string(difficulty) ~ newline;
362 				file ~= "deplete-hunger = " ~ to!string(depleteHunger) ~ newline;
363 				file ~= "do-daylight-cycle = " ~ to!string(doDaylightCycle) ~ newline;
364 				file ~= "do-entity-drops = " ~ to!string(doEntityDrops) ~ newline;
365 				file ~= "do-fire-tick = " ~ to!string(doFireTick) ~ newline;
366 				file ~= "do-scheduled-ticks = " ~ to!string(doScheduledTicks) ~ newline;
367 				file ~= "do-weather-cycle = " ~ to!string(doWeatherCycle) ~ newline;
368 				file ~= "natural-regeneration = " ~ to!string(naturalRegeneration) ~ newline;
369 				file ~= "pvp = " ~ to!string(pvp) ~ newline;
370 				file ~= "random-tick-speed = " ~ to!string(randomTickSpeed) ~ newline;
371 				file ~= "view-distance = " ~ to!string(viewDistance) ~ newline;
372 			}
373 			if(isNode) with(this.node) {
374 				file ~= newline ~ "[command]" ~ newline;
375 				foreach(command ; Commands) {
376 					file ~= command ~ " = " ~ to!string(mixin(command ~ "Command")) ~ newline;
377 				}
378 			}
379 			if(isHub) with(this.hub) {
380 				file ~= newline ~ "[rcon]" ~ newline;
381 				file ~= "enabled = " ~ to!string(rcon) ~ newline;
382 				file ~= "password = \"" ~ rconPassword ~ "\"" ~ newline;
383 				file ~= "addresses = " ~ addressString(rconAddresses) ~ newline;
384 			}
385 			if(isHub) with(this.hub) {
386 				file ~= newline ~ "[web-view]" ~ newline;
387 				file ~= "enabled = " ~ to!string(webView) ~ newline;
388 				file ~= "addresses = " ~ addressString(webViewAddresses) ~ newline;
389 			}
390 			if(isHub) with(this.hub) {
391 				file ~= newline ~ "[web-admin]" ~ newline;
392 				file ~= "enabled = " ~ to!string(webAdmin) ~ newline;
393 				file ~= "open-on-startup = " ~ to!string(webAdminOpen) ~ newline;
394 				file ~= "addresses = " ~ addressString(webAdminAddresses) ~ newline;
395 				file ~= "password = \"" ~ webAdminPassword ~ "\"" ~ newline;
396 				file ~= "max-clients = " ~ to!string(webAdminMaxClients) ~ newline;
397 			}
398 			if(type == ConfigType.hub) with(this.hub) {
399 				file ~= newline ~ "[hncom]" ~ newline;
400 				file ~= "accepted-addresses = " ~ to!string(acceptedNodes) ~ newline;
401 				file ~= "password = \"" ~ hncomPassword ~ "\"" ~ newline;
402 				file ~= "node-limit = " ~ (maxNodes == 0 ? "\"unlimited\"" : to!string(maxNodes)) ~ newline;
403 				file ~= "port = " ~ to!string(hncomPort);
404 				file ~= newline;
405 			}
406 			
407 			write(filename, file);
408 		
409 		}
410 		
411 	};
412 	
413 	if(hasArg("--reset") || hasArg("-r")) remove(filename);
414 	
415 	config.load();
416 	
417 	if(hasArg("--update-config") || hasArg("-uc")) config.save();
418 	
419 	return config;
420 
421 }
422 
423 class CompressedFiles : Files {
424 	
425 	private ZipArchive archive;
426 	
427 	public this(ZipArchive archive, string temp) {
428 		super("", temp);
429 		this.archive = archive;
430 	}
431 	
432 	public override inout bool hasAsset(string file) {
433 		return !!(convert(file) in (cast()this.archive).directory);
434 	}
435 	
436 	public override inout void[] readAsset(string file) {
437 		auto member = (cast()this.archive).directory[convert(file)];
438 		if(member.expandedData.length != member.expandedSize) (cast()this.archive).expand(member);
439 		return cast(void[])member.expandedData;
440 	}
441 	
442 	private static string convert(string file) {
443 		version(Windows) file = file.replace("\\", "/");
444 		while(file[$-1] == '/') file = file[0..$-1];
445 		return file;
446 	}
447 	
448 }
449 
450 /**
451  * Throws: ConvException
452  */
453 Config.Hub.Address convertAddress(string str) {
454 	Config.Hub.Address address;
455 	auto s = str.split(":");
456 	if(s.length >= 2) {
457 		address.port = to!ushort(s[$-1]);
458 		address.ip = s[0..$-1].join(":");
459 		if(address.ip.startsWith("[")) address.ip = address.ip[1..$];
460 		if(address.ip.endsWith("]")) address.ip = address.ip[0..$-1];
461 	}
462 	return address;
463 }
464 
465 string addressString(Config.Hub.Address[] addresses) {
466 	string[] ret;
467 	foreach(address ; addresses) {
468 		ret ~= address.toString();
469 	}
470 	return to!string(ret);
471 }