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 }