1 /**
2 	MongoDB client connection settings.
3 
4 	Copyright: © 2012-2016 RejectedSoftware e.K.
5 	License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
6 	Authors: Sönke Ludwig
7 */
8 module vibe.db.mongo.settings;
9 
10 import vibe.core.log;
11 import vibe.data.bson;
12 import vibe.db.mongo.flags : QueryFlags;
13 import vibe.inet.webform;
14 
15 import std.conv : to;
16 import std.digest.digest : toHexString;
17 import std.digest.md : md5Of;
18 import std.algorithm : splitter, startsWith;
19 import std.string : icmp, indexOf, toLower;
20 
21 
22 /**
23  * Parses the given string as a mongodb URL. The URL must be in the form documented at
24  * $(LINK http://www.mongodb.org/display/DOCS/Connections) which is:
25  *
26  * mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]
27  *
28  * Returns: true if the URL was successfully parsed. False if the URL can not be parsed.
29  *
30  * If the URL is successfully parsed the MongoClientSettings instance will contain the parsed config.
31  * If the URL is not successfully parsed the information in the MongoClientSettings instance may be
32  * incomplete and should not be used.
33  */
34 bool parseMongoDBUrl(out MongoClientSettings cfg, string url)
35 @safe {
36 	import std.exception : enforce;
37 
38 	cfg = new MongoClientSettings();
39 
40 	string tmpUrl = url[0..$]; // Slice of the url (not a copy)
41 
42 	if( !startsWith(tmpUrl, "mongodb://") )
43 	{
44 		return false;
45 	}
46 
47 	// Reslice to get rid of 'mongodb://'
48 	tmpUrl = tmpUrl[10..$];
49 
50 	auto authIndex = tmpUrl.indexOf('@');
51 	sizediff_t hostIndex = 0; // Start of the host portion of the URL.
52 
53 	// Parse out the username and optional password.
54 	if( authIndex != -1 )
55 	{
56 		// Set the host start to after the '@'
57 		hostIndex = authIndex + 1;
58 		string password;
59 
60 		auto colonIndex = tmpUrl[0..authIndex].indexOf(':');
61 		if(colonIndex != -1)
62 		{
63 			cfg.username = tmpUrl[0..colonIndex];
64 			password = tmpUrl[colonIndex + 1 .. authIndex];
65 		} else {
66 			cfg.username = tmpUrl[0..authIndex];
67 		}
68 
69 		// Make sure the username is not empty. If it is then the parse failed.
70 		if(cfg.username.length == 0)
71 		{
72 			return false;
73 		}
74 
75 		cfg.digest = MongoClientSettings.makeDigest(cfg.username, password);
76 	}
77 
78 	auto slashIndex = tmpUrl[hostIndex..$].indexOf("/");
79 	if( slashIndex == -1 ) slashIndex = tmpUrl.length;
80 	else slashIndex += hostIndex;
81 
82 	// Parse the hosts section.
83 	try
84 	{
85 		foreach(entry; splitter(tmpUrl[hostIndex..slashIndex], ","))
86 		{
87 			auto hostPort = splitter(entry, ":");
88 			string host = hostPort.front;
89 			hostPort.popFront();
90 			ushort port = MongoClientSettings.defaultPort;
91 			if (!hostPort.empty) {
92 				port = to!ushort(hostPort.front);
93 				hostPort.popFront();
94 			}
95 			enforce(hostPort.empty, "Host specifications are expected to be of the form \"HOST:PORT,HOST:PORT,...\".");
96 			cfg.hosts ~= MongoHost(host, port);
97 		}
98 	} catch (Exception e) {
99 		return  false; // Probably failed converting the port to ushort.
100 	}
101 
102 	// If we couldn't parse a host we failed.
103 	if(cfg.hosts.length == 0)
104 	{
105 		return false;
106 	}
107 
108 	if(slashIndex == tmpUrl.length)
109 	{
110 		// We're done parsing.
111 		return true;
112 	}
113 
114 	auto queryIndex = tmpUrl[slashIndex..$].indexOf("?");
115 	if(queryIndex == -1){
116 		// No query string. Remaining string is the database
117 		queryIndex = tmpUrl.length;
118 	} else {
119 		queryIndex += slashIndex;
120 	}
121 
122 	cfg.database = tmpUrl[slashIndex+1..queryIndex];
123 	if(queryIndex != tmpUrl.length)
124 	{
125 		FormFields options;
126 		parseURLEncodedForm(tmpUrl[queryIndex+1 .. $], options);
127 		foreach (option, value; options) {
128 			bool setBool(ref bool dst)
129 			{
130 				try {
131 					dst = to!bool(value);
132 					return true;
133 				} catch( Exception e ){
134 					logError("Value for '%s' must be 'true' or 'false' but was '%s'.", option, value);
135 					return false;
136 				}
137 			}
138 
139 			bool setLong(ref long dst)
140 			{
141 				try {
142 					dst = to!long(value);
143 					return true;
144 				} catch( Exception e ){
145 					logError("Value for '%s' must be an integer but was '%s'.", option, value);
146 					return false;
147 				}
148 			}
149 
150 			void warnNotImplemented()
151 			{
152 				logDiagnostic("MongoDB option %s not yet implemented.", option);
153 			}
154 
155 			switch( option.toLower() ){
156 				default: logWarn("Unknown MongoDB option %s", option); break;
157 				case "slaveok": bool v; if( setBool(v) && v ) cfg.defQueryFlags |= QueryFlags.SlaveOk; break;
158 				case "replicaset": cfg.replicaSet = value; warnNotImplemented(); break;
159 				case "safe": setBool(cfg.safe); break;
160 				case "fsync": setBool(cfg.fsync); break;
161 				case "journal": setBool(cfg.journal); break;
162 				case "connecttimeoutms": setLong(cfg.connectTimeoutMS); warnNotImplemented(); break;
163 				case "sockettimeoutms": setLong(cfg.socketTimeoutMS); warnNotImplemented(); break;
164 				case "ssl": setBool(cfg.ssl); break;
165 				case "sslverifycertificate": setBool(cfg.sslverifycertificate); break;
166 				case "authmechanism": cfg.authMechanism = parseAuthMechanism(value); break;
167 				case "wtimeoutms": setLong(cfg.wTimeoutMS); break;
168 				case "w":
169 					try {
170 						if(icmp(value, "majority") == 0){
171 							cfg.w = Bson("majority");
172 						} else {
173 							cfg.w = Bson(to!long(value));
174 						}
175 					} catch (Exception e) {
176 						logError("Invalid w value: [%s] Should be an integer number or 'majority'", value);
177 					}
178 				break;
179 			}
180 		}
181 
182 		/* Some m_settings imply safe. If they are set, set safe to true regardless
183 		 * of what it was set to in the URL string
184 		 */
185 		if( (cfg.w != Bson.init) || (cfg.wTimeoutMS != long.init) ||
186 				cfg.journal 	 || cfg.fsync )
187 		{
188 			cfg.safe = true;
189 		}
190 	}
191 
192 	return true;
193 }
194 
195 /* Test for parseMongoDBUrl */
196 unittest
197 {
198 	MongoClientSettings cfg;
199 
200 	assert(parseMongoDBUrl(cfg, "mongodb://localhost"));
201 	assert(cfg.hosts.length == 1);
202 	assert(cfg.database == "");
203 	assert(cfg.hosts[0].name == "localhost");
204 	assert(cfg.hosts[0].port == 27017);
205 	assert(cfg.defQueryFlags == QueryFlags.None);
206 	assert(cfg.replicaSet == "");
207 	assert(cfg.safe == false);
208 	assert(cfg.w == Bson.init);
209 	assert(cfg.wTimeoutMS == long.init);
210 	assert(cfg.fsync == false);
211 	assert(cfg.journal == false);
212 	assert(cfg.connectTimeoutMS == long.init);
213 	assert(cfg.socketTimeoutMS == long.init);
214 	assert(cfg.ssl == bool.init);
215 	assert(cfg.sslverifycertificate == true);
216 
217 	cfg = MongoClientSettings.init;
218 	assert(parseMongoDBUrl(cfg, "mongodb://fred:foobar@localhost"));
219 	assert(cfg.username == "fred");
220 	//assert(cfg.password == "foobar");
221 	assert(cfg.digest == MongoClientSettings.makeDigest("fred", "foobar"));
222 	assert(cfg.hosts.length == 1);
223 	assert(cfg.database == "");
224 	assert(cfg.hosts[0].name == "localhost");
225 	assert(cfg.hosts[0].port == 27017);
226 
227 	cfg = MongoClientSettings.init;
228 	assert(parseMongoDBUrl(cfg, "mongodb://fred:@localhost/baz"));
229 	assert(cfg.username == "fred");
230 	//assert(cfg.password == "");
231 	assert(cfg.digest == MongoClientSettings.makeDigest("fred", ""));
232 	assert(cfg.database == "baz");
233 	assert(cfg.hosts.length == 1);
234 	assert(cfg.hosts[0].name == "localhost");
235 	assert(cfg.hosts[0].port == 27017);
236 
237 	cfg = MongoClientSettings.init;
238 	assert(parseMongoDBUrl(cfg, "mongodb://host1,host2,host3/?safe=true&w=2&wtimeoutMS=2000&slaveOk=true&ssl=true&sslverifycertificate=false"));
239 	assert(cfg.username == "");
240 	//assert(cfg.password == "");
241 	assert(cfg.digest == "");
242 	assert(cfg.database == "");
243 	assert(cfg.hosts.length == 3);
244 	assert(cfg.hosts[0].name == "host1");
245 	assert(cfg.hosts[0].port == 27017);
246 	assert(cfg.hosts[1].name == "host2");
247 	assert(cfg.hosts[1].port == 27017);
248 	assert(cfg.hosts[2].name == "host3");
249 	assert(cfg.hosts[2].port == 27017);
250 	assert(cfg.safe == true);
251 	assert(cfg.w == Bson(2L));
252 	assert(cfg.wTimeoutMS == 2000);
253 	assert(cfg.defQueryFlags == QueryFlags.SlaveOk);
254 	assert(cfg.ssl == true);
255 	assert(cfg.sslverifycertificate == false);
256 
257 	cfg = MongoClientSettings.init;
258 	assert(parseMongoDBUrl(cfg,
259 				"mongodb://fred:flinstone@host1.example.com,host2.other.example.com:27108,host3:"
260 				~ "27019/mydb?journal=true;fsync=true;connectTimeoutms=1500;sockettimeoutMs=1000;w=majority"));
261 	assert(cfg.username == "fred");
262 	//assert(cfg.password == "flinstone");
263 	assert(cfg.digest == MongoClientSettings.makeDigest("fred", "flinstone"));
264 	assert(cfg.database == "mydb");
265 	assert(cfg.hosts.length == 3);
266 	assert(cfg.hosts[0].name == "host1.example.com");
267 	assert(cfg.hosts[0].port == 27017);
268 	assert(cfg.hosts[1].name == "host2.other.example.com");
269 	assert(cfg.hosts[1].port == 27108);
270 	assert(cfg.hosts[2].name == "host3");
271 	assert(cfg.hosts[2].port == 27019);
272 	assert(cfg.fsync == true);
273 	assert(cfg.journal == true);
274 	assert(cfg.connectTimeoutMS == 1500);
275 	assert(cfg.socketTimeoutMS == 1000);
276 	assert(cfg.w == Bson("majority"));
277 	assert(cfg.safe == true);
278 
279 	// Invalid URLs - these should fail to parse
280 	cfg = MongoClientSettings.init;
281 	assert(! (parseMongoDBUrl(cfg, "localhost:27018")));
282 	assert(! (parseMongoDBUrl(cfg, "http://blah")));
283 	assert(! (parseMongoDBUrl(cfg, "mongodb://@localhost")));
284 	assert(! (parseMongoDBUrl(cfg, "mongodb://:thepass@localhost")));
285 	assert(! (parseMongoDBUrl(cfg, "mongodb://:badport/")));
286 
287 	assert(parseMongoDBUrl(cfg, "mongodb://me:sl$ash/w0+rd@localhost"));
288 	assert(cfg.digest == MongoClientSettings.makeDigest("me", "sl$ash/w0+rd"));
289 	assert(cfg.hosts.length == 1);
290 	assert(cfg.hosts[0].name == "localhost");
291 	assert(cfg.hosts[0].port == 27017);
292 	assert(parseMongoDBUrl(cfg, "mongodb://me:sl$ash/w0+rd@localhost/mydb"));
293 	assert(cfg.digest == MongoClientSettings.makeDigest("me", "sl$ash/w0+rd"));
294 	assert(cfg.database == "mydb");
295 	assert(cfg.hosts.length == 1);
296 	assert(cfg.hosts[0].name == "localhost");
297 	assert(cfg.hosts[0].port == 27017);
298 }
299 
300 enum MongoAuthMechanism
301 {
302 	none,
303 	scramSHA1,
304 	mongoDBCR,
305 	mongoDBX509
306 }
307 
308 private MongoAuthMechanism parseAuthMechanism(string str)
309 @safe {
310 	switch (str) {
311 		case "SCRAM-SHA-1": return MongoAuthMechanism.scramSHA1;
312 		case "MONGODB-CR": return MongoAuthMechanism.mongoDBCR;
313 		case "MONGODB-X509": return MongoAuthMechanism.mongoDBX509;
314 		default: throw new Exception("Auth mechanism \"" ~ str ~ "\" not supported");
315 	}
316 }
317 
318 class MongoClientSettings
319 {
320 	enum ushort defaultPort = 27017;
321 
322 	string username;
323 	string digest;
324 	MongoHost[] hosts;
325 	string database;
326 	QueryFlags defQueryFlags = QueryFlags.None;
327 	string replicaSet;
328 	bool safe;
329 	Bson w; // Either a number or the string 'majority'
330 	long wTimeoutMS;
331 	bool fsync;
332 	bool journal;
333 	long connectTimeoutMS;
334 	long socketTimeoutMS;
335 	bool ssl;
336 	bool sslverifycertificate = true;
337 	string sslPEMKeyFile;
338 	string sslCAFile;
339 	MongoAuthMechanism authMechanism;
340 
341 	static string makeDigest(string username, string password)
342 	@safe {
343 		return md5Of(username ~ ":mongo:" ~ password).toHexString().idup.toLower();
344 	}
345 }
346 
347 struct MongoHost
348 {
349 	string name;
350 	ushort port;
351 }