1 /// Written in the D programming language.
2 
3 module semitwistWeb.db;
4 
5 import std.array;
6 import std.conv;
7 import std.datetime;
8 import std.digest.sha;
9 import std.exception;
10 import std.regex;
11 import std..string;
12 import std.traits;
13 import std.typecons;
14 import std.typetuple;
15 
16 import vibe.vibe;
17 import vibe.core.connectionpool;
18 
19 import mysql.db;
20 import semitwist.util.all;
21 import semitwistWeb.conf;
22 import semitwistWeb.util;
23 
24 bool dbHelperLogSql;
25 LockedConnection!Connection delegate() dbHelperOpenDB;
26 
27 private void ensureOpenDBIsSet()
28 {
29 	if(dbHelperOpenDB is null)
30 		throw new Exception("dbHelperOpenDB has not been set.");
31 }
32 
33 string mySqlString()(string str)
34 {
35 	if(str is null)
36 		return "NULL";
37 
38 	return "'" ~ str
39 		.replace(`\`,  `\\`)
40 		.replace("\0", `\0`)
41 		.replace("'",  `\'`)
42 		.replace("\b", `\b`)
43 		.replace("\n", `\n`)
44 		.replace("\r", `\r`)
45 		.replace("\t", `\t`)
46 		.replace("\x1A", `\Z`) ~ "'";
47 }
48 
49 string mySqlString(T)(Nullable!T value)
50 {
51 	return value.isNull? "NULL" : mySqlString(value.get());
52 }
53 
54 string mySqlString(T)(T value) if(isNumeric!T)
55 {
56 	return to!string(value);
57 }
58 
59 // Template param needed to work around DMD Issue #16484
60 string mySqlString(T)(T dateTime) if(is(T==DateTime))
61 {
62 	return mySqlString( mySqlDateTime(dateTime) );
63 }
64 
65 string mySqlDateTime(DateTime dateTime)
66 {
67 	return
68 		"%#.4d-%#.2d-%#.2d %#.2d:%#.2d:%#.2d".format(
69 			dateTime.year, dateTime.month, dateTime.day,
70 			dateTime.hour, dateTime.minute, dateTime.second
71 		);
72 }
73 
74 T getNullable(T)(Row row, size_t index) if(isSomeString!T)
75 {
76 	if(row.isNull(index))
77 		return null;
78 
79 	return row[index].coerce!T();
80 }
81 
82 Nullable!T getNullable(T)(Row row, size_t index) if(is(T==DateTime))
83 {
84 	if(row.isNull(index))
85 		return Nullable!T();
86 
87 	return Nullable!T( row[index].get!T() );
88 }
89 
90 Nullable!T getNullable(T)(Row row, size_t index) if(!isSomeString!T && !is(T==DateTime))
91 {
92 	if(row.isNull(index))
93 		return Nullable!T();
94 
95 	return Nullable!T( row[index].coerce!T() );
96 }
97 
98 /// Runs 'res/init.sql' on the DB.
99 /// Ie, Completely clears the DB and creates the needed tables.
100 void initializeDB()
101 {
102 	int port = conf.dbPort; // WhyTF was this needed?
103 	stLogInfo(
104 		format(
105 			"Initializing MySQL DB at %s:%s '%s', user '%s'...",
106 			conf.dbHost, port, conf.dbName, conf.dbUser
107 		)
108 	);
109 
110 	scope(failure)
111 	{
112 		stLogError(
113 			"There was an error initializing the database.\n",
114 			import("dbTroubleshootMsg.txt")
115 		);
116 	}
117 
118 	auto dbConn = dbHelperOpenDB();
119 	auto db = Command(dbConn);
120 
121 	auto sqlInitScript = import("init.sql");
122 	auto sqlInitStatements = sqlInitScript.split(";");
123 	foreach(sql; sqlInitStatements)
124 	{
125 		sql = sql.strip();
126 		if(sql != "")
127 		{
128 			db.sql = sql;
129 			db.runSQL();
130 		}
131 	}
132 
133 	stLogInfo("Initializing DB done.");
134 }
135 
136 string[uint] getDBColumns(Connection dbConn, string dbName, string tableName)
137 {
138 	auto db = Command(dbConn);
139 	db.sql = text(
140 		"SELECT `COLUMN_NAME`, `ORDINAL_POSITION` FROM `INFORMATION_SCHEMA`.`COLUMNS` WHERE",
141 		" `TABLE_SCHEMA`=", mySqlString(dbName), " AND `TABLE_NAME`=", mySqlString(tableName)
142 	);
143 	auto rows = db.execSQLResult();
144 
145 	string[uint] result;
146 	foreach(row; rows)
147 		result[row[1/+"ORDINAL_POSITION"+/].coerce!uint()] = row[0/+"COLUMN_NAME"+/].toString();
148 	
149 	return result;
150 }
151 
152 string[] getDBKeys(Connection dbConn, string dbName, string tableName)
153 {
154 	auto db = Command(dbConn);
155 	db.sql = text(
156 		"SELECT `COLUMN_NAME` FROM `INFORMATION_SCHEMA`.`KEY_COLUMN_USAGE` WHERE",
157 		" `TABLE_SCHEMA`=", mySqlString(dbName), " AND `TABLE_NAME`=", mySqlString(tableName)
158 	);
159 	auto rows = db.execSQLResult();
160 
161 	string[] result;
162 	result.length = rows.length;
163 	size_t i=0;
164 	foreach(row; rows)
165 	{
166 		result[i] = row[0/+"COLUMN_NAME"+/].toString();
167 		i++;
168 	}
169 	
170 	return result;
171 }
172 
173 bool runSQL(Connection dbConn, string sql)
174 {
175 	auto db = Command(dbConn);
176 	db.sql = sql;
177 	return db.runSQL();
178 }
179 
180 bool runSQL(Connection dbConn, ref ulong rowsAffected, string sql)
181 {
182 	auto db = Command(dbConn);
183 	db.sql = sql;
184 	return db.runSQL(rowsAffected);
185 }
186 
187 bool runSQL(ref Command db)
188 {
189 	ulong rowsAffected;
190 	return runSQL(db, rowsAffected);
191 }
192 
193 bool runSQL(ref Command db, ref ulong rowsAffected)
194 {
195 	if(dbHelperLogSql)
196 		stLogInfo("runSQL: ", db.sql);
197 
198 	return db.execSQL(rowsAffected);
199 }
200 
201 bool dboRunSQL(T)(Connection dbConn, T dbo, string sql)
202 {
203 	auto db = Command(dbConn);
204 	db.sql = sql;
205 	return db.dboRunSQL(dbo);
206 }
207 
208 bool dboRunSQL(T)(Connection dbConn, T dbo, ref ulong rowsAffected, string sql)
209 {
210 	auto db = Command(dbConn);
211 	db.sql = sql;
212 	return db.dboRunSQL(dbo, rowsAffected);
213 }
214 
215 bool dboRunSQL(T)(ref Command db, T dbo)
216 {
217 	ulong rowsAffected;
218 	return dboRunSQL(db, dbo, rowsAffected);
219 }
220 
221 bool dboRunSQL(T)(ref Command db, T dbo, ref ulong rowsAffected)
222 {
223 	if(dbHelperLogSql)
224 		stLogInfo("dboRunSQL: ", db.sql);
225 
226 	ensureOpenDBIsSet();
227 
228 	try
229 		return db.execSQL(rowsAffected);
230 	catch(MySQLReceivedException e)
231 		convertMySQLToDBO(e, dbo);
232 	assert(0);
233 }
234 
235 ResultSet dboRunSQLResult(T)(ref Command db, T dbo, ColumnSpecialization[] csa = null)
236 {
237 	if(dbHelperLogSql)
238 		stLogInfo("dboRunSQLResult: ", db.sql);
239 
240 	ensureOpenDBIsSet();
241 
242 	try
243 		return db.execSQLResult(csa);
244 	catch(MySQLReceivedException e)
245 		convertMySQLToDBO(e, dbo);
246 	assert(0);
247 }
248 
249 void convertMySQLToDBO(TDbo)(MySQLReceivedException e, TDbo dbo)
250 {
251 	//TODO: Add enums for error codes to mysql client driver
252 	if(e.errorCode == 1062)
253 		throw DBODuplicateEntryException.fromDBO(dbo, e);
254 	else
255 		throw e;
256 }
257 
258 class DBOException : Exception
259 {
260 	this(string msg) { super(msg); }
261 }
262 
263 class DBODuplicateEntryException : DBOException
264 {
265 	TypeInfo type;
266 	string fieldName;
267 	MySQLReceivedException mySqlException;
268 	
269 	this(TypeInfo type, string fieldName, MySQLReceivedException e)
270 	{
271 		this.type = type;
272 		this.fieldName = fieldName;
273 		this.mySqlException = e;
274 
275 		string fieldMsg = fieldName==""? "{unknown}" : fieldName;
276 		super(text("Duplicate entry in column '", fieldMsg, "' for '", type, "'"));
277 	}
278 	
279 	static typeof(this) fromDBO(T)(T dbo, MySQLReceivedException e)
280 	{
281 		ensureOpenDBIsSet();
282 		
283 		enum logPrefix = typeof(this).stringof ~ ".fromDBO: ";
284 
285 		// Try to parse key #
286 		string field = null;
287 		auto m = match(e.msg, regex(`key ([0-9]+)`));
288 		if(m && m.captures.length >= 2 && isNumeric(m.captures[1]))
289 		{
290 			uint keyNum;
291 			auto toUIntException = collectException!ConvException(to!uint(m.captures[1]), keyNum);
292 			if(!toUIntException)
293 			{
294 				auto keyIndex = keyNum-1;
295 				auto dbKeys = T.dbo.dbKeys(dbHelperOpenDB);
296 				if(keyIndex < dbKeys.length)
297 					field = dbKeys[keyIndex];
298 				else
299 				{
300 					stLogWarn(
301 						logPrefix,
302 						"Parsed key index ", keyIndex,
303 						", but metadata contains only ", dbKeys.length,
304 						" key(s)."/+, " Rebuilding DB cache."+/,
305 					);
306 					//rebuildDBCache(dbHelperOpenDB); // Can't do this, don't know the user's DBOs
307 				}
308 			}
309 		}
310 		
311 		if(field == "")
312 		{
313 			// Try to parse key name
314 			m = match(e.msg, regex(`key '([^']+)'`));
315 			if(m && m.captures.length >= 2)
316 			{
317 				string keyName = m.captures[1];
318 				auto dbKeys = T.dbo.dbKeys(dbHelperOpenDB);
319 				foreach(k,v; dbKeys)
320 				if(v == keyName)
321 				{
322 					field = keyName;
323 					break;
324 				}
325 				
326 				if(field == "")
327 				{
328 					stLogWarn(
329 						logPrefix,
330 						"Parsed key name '", keyName,
331 						"', but couldn't find such a key name in the metadata (",
332 						dbKeys, ")."/+, " Rebuilding DB cache."+/,
333 					);
334 					//rebuildDBCache(dbHelperOpenDB); // Can't do this, don't know the user's DBOs
335 				}
336 			}
337 		}
338 
339 		if(field == "")
340 			stLogWarn(
341 				logPrefix, "Couldn't parse field name in MySQL server's error message: ",
342 				e.msg
343 			);
344 
345 		return new typeof(this)(typeid(T), field, e);
346 	}
347 }
348 
349 alias TypeTuple!(
350 	SessionDB,
351 	Token,
352 ) DBOHelperTypes;
353 
354 void clearDBCache(UserDBOTypes...)()
355 {
356 	foreach(T; TypeTuple!(UserDBOTypes, DBOHelperTypes))
357 		T.dbo.clearDBCache();
358 }
359 
360 void fillDBCache(UserDBOTypes...)(Connection dbConn)
361 {
362 	foreach(T; TypeTuple!(UserDBOTypes, DBOHelperTypes))
363 		T.dbo.fillDBCache(dbConn);
364 }
365 
366 void rebuildDBCache(UserDBOTypes...)(Connection dbConn)
367 {
368 	foreach(T; TypeTuple!(UserDBOTypes, DBOHelperTypes))
369 		T.dbo.rebuildDBCache(dbConn);
370 }
371 
372 //TODO: Make dbName and dbTable runtime values, maybe as a defineDynamicDBO if necessary
373 mixin template defineDBO(alias dbTable)
374 {
375 	private static DBO _dbo;
376 	static @property DBO dbo()
377 	{
378 		if(_dbo is null)
379 		{
380 			//throw new Exception("dbo is null");
381 			_dbo = new DBO(dbTable);
382 		}
383 		
384 		return _dbo;
385 	}
386 }
387 
388 final class DBO
389 {
390 	this(string dbTable, string dbName=null)
391 	{
392 		if(dbName == "")
393 			dbName = conf.dbName;
394 
395 		this._dbName = dbName;
396 		this._dbTable = dbTable;
397 	}
398 	
399 	private string _dbName;
400 	private string _dbTable;
401 	@property string dbName() { return _dbName; }
402 	@property string dbTable() { return _dbTable; }
403 
404 	/**
405 	Metadata
406 	
407 	Note:
408 	To avoid possibility of race conditions (after all, this DOES do
409 	potentially fiber-switching IO), the members dbColumns and dbKeys
410 	are not built in-place, but rather built and *then* set atomically.
411 	
412 	It's OK if multiple fibers try to rebuild them simultaneously,
413 	because both fiber's results will be the same.
414 
415 	WARNING!
416 	If you do IO or otherwise yield to another fiber, the values for dbColumns
417 	and dbKeys could change or become null. Therefore, you should always copy
418 	these to a local reference once and use that, instead of directly accessing
419 	these multiple times in the same loop/function/operation/etc. Ie:
420 	
421 	GOOD:
422 	auto columns = MyDBObject.dbo.dbColumns();
423 	if(5 in columns)
424 		return columns[5];
425 	
426 	BAD:
427 	if(5 in MyDBObject.dbo.dbColumns())
428 		return MyDBObject.dbo.dbColumns()[5];
429 	*/
430 	private string[uint] _dbColumns;
431 	string[uint] dbColumns(LockedConnection!Connection delegate() openDB) /// ditto
432 	{
433 		if(!_dbColumns)
434 		{
435 			auto dbConn = openDB();
436 			return dbColumns(dbConn);
437 		}
438 		
439 		return dbColumns(cast(Connection)null);
440 	}
441 	string[uint] dbColumns(Connection dbConn) /// ditto
442 	{
443 		if(!_dbColumns)
444 			_dbColumns = getDBColumns(dbConn, dbName, dbTable);
445 		
446 		return _dbColumns;
447 	}
448 
449 	private string[] _dbKeys; /// ditto
450 	string[] dbKeys(LockedConnection!Connection delegate() openDB) /// ditto
451 	{
452 		if(!_dbKeys)
453 		{
454 			auto dbConn = openDB();
455 			return dbKeys(dbConn);
456 		}
457 
458 		return dbKeys(cast(Connection)null);
459 	}
460 	string[] dbKeys(Connection dbConn) /// ditto
461 	{
462 		if(!_dbKeys)
463 			_dbKeys = getDBKeys(dbConn, dbName, dbTable);
464 		
465 		return _dbKeys;
466 	}
467 	
468 	// Cache control
469 	void clearDBCache()
470 	{
471 		_dbColumns = null;
472 		_dbKeys = null;
473 	}
474 	
475 	void fillDBCache(LockedConnection!Connection delegate() openDB)
476 	{
477 		if(!_dbColumns || !_dbKeys)
478 		{
479 			auto dbConn = openDB();
480 			fillDBCache(dbConn);
481 		}
482 		
483 		fillDBCache(cast(Connection)null);
484 	}
485 
486 	void fillDBCache(Connection dbConn)
487 	{
488 		auto c = dbColumns(dbConn);
489 		auto k = dbKeys(dbConn);
490 	}
491 	
492 	void rebuildDBCache(LockedConnection!Connection delegate() openDB)
493 	{
494 		if(!_dbColumns || !_dbKeys)
495 		{
496 			auto dbConn = openDB();
497 			rebuildDBCache(dbConn);
498 		}
499 		
500 		rebuildDBCache(cast(Connection)null);
501 	}
502 
503 	void rebuildDBCache(Connection dbConn)
504 	{
505 		clearDBCache();
506 		fillDBCache(dbConn);
507 	}
508 }
509 
510 struct SessionDB
511 {
512 	string id;
513 	string userId;
514 	
515 	mixin defineDBO!("session");
516 	
517 	void dbInsert(Connection dbConn)
518 	{
519 		auto db = Command(dbConn);
520 		db.sql = text(
521 			"INSERT INTO `", dbo.dbTable, "` (",
522 			"`id`, `userId`",
523 			") VALUES (",
524 			mySqlString(id), ", ", mySqlString(userId),
525 			")"
526 		);
527 		db.dboRunSQL(this);
528 	}
529 	
530 	void dbDelete(Connection dbConn)
531 	{
532 		auto db = Command(dbConn);
533 		db.sql = text("DELETE IGNORE FROM `", dbo.dbTable, "` WHERE `id` = ", mySqlString(id));
534 		db.dboRunSQL(this);
535 	}
536 	
537 	static void dbDeleteAll(Connection dbConn)
538 	{
539 		auto db = Command(dbConn);
540 		db.sql = text("DELETE FROM `", dbo.dbTable, "`");
541 		db.dboRunSQL(SessionDB());
542 	}
543 	
544 	static SessionDB[] getAll(Connection dbConn)
545 	{
546 		SessionDB[] sessions;
547 		
548 		auto db = Command(dbConn);
549 		db.sql = text("SELECT `id`, `userId` FROM `", dbo.dbTable, "`");
550 		auto rows = db.dboRunSQLResult(SessionDB());
551 		if(rows.length == 0)
552 			return sessions;
553 		
554 		foreach(row; rows)
555 		{
556 			SessionDB sess;
557 			sess.id     = row[0/+"id"+/    ].toString();
558 			sess.userId = row[1/+"userId"+/].toString();
559 			sessions ~= sess;
560 		}
561 		
562 		return sessions;
563 	}
564 }
565 
566 struct Token
567 {
568 	string type;
569 	string code;
570 	string email;
571 	DateTime expiration;
572 	ulong linkedId;
573 	
574 	mixin defineDBO!("token");
575 	
576 	void dbInsert(Connection dbConn)
577 	{
578 		auto db = Command(dbConn);
579 		db.sql = text(
580 			"INSERT INTO `", dbo.dbTable, "` (",
581 			"`type`, `code`, `expiration`, `email`, `linkedId`",
582 			") VALUES (",
583 			mySqlString(type), ", ", mySqlString(code), ", ", mySqlString(expiration), ", ",
584 			mySqlString(email), ", ", linkedId,
585 			")"
586 		);
587 		db.dboRunSQL(this);
588 	}
589 	
590 	void dbDelete(Connection dbConn)
591 	{
592 		auto db = Command(dbConn);
593 		db.sql = text(
594 			"DELETE FROM `", dbo.dbTable, "` WHERE `email`=", mySqlString(email),
595 			" AND `type`=", mySqlString(type), " AND `code`=", mySqlString(code)
596 		);
597 		db.dboRunSQL(this);
598 	}
599 
600 	static Nullable!Token validate(Connection dbConn, string type, string code, string email)
601 	{
602 		Nullable!Token ret;
603 		auto db = Command(dbConn);
604 		db.sql = text(
605 			"SELECT `expiration`, `linkedId` FROM `"~dbo.dbTable~"`"~
606 			" WHERE `email`="~mySqlString(email)~
607 			" AND `type`="~mySqlString(type)~
608 			" AND `code`="~mySqlString(code)~
609 			" AND `expiration` >= NOW()"
610 		);
611 		auto rows = db.dboRunSQLResult(Token());
612 		if(rows.length == 0)
613 			return ret;
614 		
615 		Token token;
616 		token.type  = type;
617 		token.code  = code;
618 		token.email = email;
619 		token.expiration = rows[0][0/+"expiration"+/].get!DateTime();
620 		token.linkedId   = rows[0][1/+"linkedId"+/].coerce!ulong();
621 		
622 		ret = token;
623 		return ret;
624 	}
625 	
626 	//TODO*: Redo to use more chars so code is shorter. Also, skip the emailHash
627 	//       because making the same chars totally random is mathematically better anyway.
628 	//       In other words, this is temporarily crap and needs re-done.
629 	private static string genCode(string email)
630 	{
631 		auto emailHash = sha1Of(email);
632 		auto rnd = randomBytes(16);
633 		
634 		auto combined =
635 			(ubyte[]).init ~
636 			rnd[0] ~ emailHash[10] ~
637 			rnd[1] ~ emailHash[11] ~
638 			rnd[2] ~ emailHash[12] ~
639 			rnd[3] ~ emailHash[13] ~
640 			rnd[4] ~ emailHash[14] ~
641 			rnd[5] ~ emailHash[15] ~
642 			rnd[6] ~ emailHash[16] ~
643 			rnd[7] ~ emailHash[17] ~
644 			rnd[8..$];
645 
646 		import semitwist.util.text : semitwistToHexString = toHexString;
647 		auto codeRaw = semitwistToHexString(combined);
648 		auto code = insertDashes(codeRaw);
649 		return code;
650 	}
651 	
652 	static Token create(
653 		Connection dbConn, string type, Duration lifetime,
654 		string email, ulong linkedId
655 	)
656 	{
657 		auto expiration = cast(DateTime)Clock.currTime() + lifetime;
658 		auto token = Token(type, "", email, expiration, linkedId);
659 		foreach(i; 0..20)
660 		{
661 			token.code = genCode(token.email);
662 			
663 			try
664 				token.dbInsert(dbConn);
665 			catch(DBODuplicateEntryException e)
666 			{
667 				if(e.fieldName == "code")
668 					continue; // Try generating another code
669 
670 				throw e;
671 			}
672 			
673 			// Token was successfully inserted into DB
674 			return token;
675 		}
676 		
677 		// Failed to generate ununsed code
678 		throw new Exception("Unable to generate unique '"~type~"' token");
679 	}
680 }