1<?php 2/** 3 * MyBB 1.8 4 * Copyright 2020 MyBB Group, All Rights Reserved 5 * 6 * Website: http://www.mybb.com 7 * License: http://www.mybb.com/about/license 8 */ 9 10abstract class AbstractPdoDbDriver implements DB_Base 11{ 12 /** 13 * Whether error reporting is enabled. 14 * 15 * @var boolean 16 */ 17 public $error_reporting = 1; 18 19 /** 20 * The read database connection resource. 21 * 22 * @var PDO|null 23 */ 24 public $read_link = null; 25 26 /** 27 * The write database connection resource. 28 * 29 * @var PDO|null 30 */ 31 public $write_link = null; 32 33 /** 34 * Reference to the last database connection resource used. 35 * 36 * @var PDO|null 37 */ 38 public $current_link = null; 39 40 /** 41 * The database name. 42 * 43 * @var string 44 */ 45 public $database; 46 47 /** 48 * The database encoding currently in use (if supported). 49 * 50 * @var string 51 */ 52 public $db_encoding = "utf8"; 53 54 /** 55 * The time spent performing queries. 56 * 57 * @var float 58 */ 59 public $query_time = 0; 60 61 /** 62 * A count of the number of queries. 63 * 64 * @var int 65 */ 66 public $query_count = 0; 67 68 /** 69 * @var array 70 */ 71 public $connections = array(); 72 73 /** 74 * @var PDOException|null 75 */ 76 private $lastPdoException; 77 78 /** 79 * The type of the previous query. 80 * 81 * 1 => write; 0 => read 82 * 83 * @var int 84 */ 85 protected $last_query_type = 0; 86 87 /** 88 * Used to store row offsets for queries when seeking. 89 * 90 * @var array 91 */ 92 private $resultSeekPositions = array(); 93 94 /** 95 * The last result, used to get the number of affected rows in {@see AbstractPdoDbDriver::affected_rows()}. 96 * 97 * @var PDOStatement|null 98 */ 99 private $lastResult = null; 100 101 /** 102 * The table prefix used for simple select, update, insert and delete queries 103 * 104 * @var string 105 */ 106 public $table_prefix; 107 108 /** 109 * The current version of the DBMS. 110 * 111 * Note that this is the version used by the {@see AbstractPdoDbDriver::$read_link}. 112 * 113 * @var string 114 */ 115 public $version; 116 117 /** 118 * A list of the performed queries. 119 * 120 * @var array 121 */ 122 public $querylist = array(); 123 124 /** 125 * The engine used to run the SQL database. 126 * 127 * @var string 128 */ 129 public $engine = "pdo"; 130 131 /** 132 * Whether or not this engine can use the search functionality. 133 * 134 * @var boolean 135 */ 136 public $can_search = true; 137 138 /** 139 * Build a DSN string using the given configuration. 140 * 141 * @param string $hostname The hostname of the database serer to connect to. 142 * @param string $db The name of the database to connect to. 143 * @param int|null The optional port to use to connect to the database server. 144 * @param string|null The character encoding to use for the connection. 145 * 146 * @return string The DSN string, including the driver prefix. 147 */ 148 protected abstract function getDsn($hostname, $db, $port, $encoding); 149 150 /** 151 * Connect to the database server. 152 * 153 * @param array $config Array of DBMS connection details. 154 * 155 * @return bool Whether opening the connection was successful. 156 */ 157 public function connect($config) 158 { 159 $connections = array( 160 'read' => array(), 161 'write' => array(), 162 ); 163 164 if (isset($config['hostname'])) { 165 // simple connection, with single DB server 166 $connections['read'][] = $config; 167 } else { 168 if (!isset($config['read'])) { 169 // multiple servers, but no specific read/write servers 170 foreach ($config as $key => $settings) { 171 if (is_int($key)) { 172 $connections['read'][] = $settings; 173 } 174 } 175 } else { 176 // both read and write servers 177 $connections = $config; 178 } 179 } 180 181 if (isset($config['encoding'])) { 182 $this->db_encoding = $config['encoding']; 183 } 184 185 // Actually connect to the specified servers 186 foreach (array('read', 'write') as $type) { 187 if (!isset($connections[$type]) || !is_array($connections[$type])){ 188 break; 189 } 190 191 if (isset($connections[$type]['hostname'])) { 192 $details = $connections[$type]; 193 unset($connections[$type]); 194 $connections[$type][] = $details; 195 } 196 197 // shuffle the connections 198 shuffle($connections[$type]); 199 200 // loop through the connections 201 foreach($connections[$type] as $singleConnection) 202 { 203 $flags = array( 204 PDO::ATTR_PERSISTENT => false, 205 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, 206 PDO::ATTR_EMULATE_PREPARES => false, 207 ); 208 209 if (!empty($singleConnection['pconnect'])) { 210 $flags[PDO::ATTR_PERSISTENT] = true; 211 } 212 213 $link = "{$type}_link"; 214 215 get_execution_time(); 216 217 list($hostname, $port) = self::parseHostname($singleConnection['hostname']); 218 219 $dsn = $this->getDsn( 220 $hostname, 221 $config['database'], 222 $port, 223 $this->db_encoding 224 ); 225 226 try { 227 $this->$link = new PDO( 228 $dsn, 229 $singleConnection['username'], 230 $singleConnection['password'], 231 $flags 232 ); 233 234 $this->lastPdoException = null; 235 } catch (PDOException $e) { 236 $this->$link = null; 237 $this->lastPdoException = $e; 238 } 239 240 $time_spent = get_execution_time(); 241 $this->query_time += $time_spent; 242 243 // Successful connection? break down brother! 244 if ($this->$link !== null) { 245 $this->connections[] = "[".strtoupper($type)."] {$singleConnection['username']}@{$singleConnection['hostname']} (Connected in ".format_time_duration($time_spent).")"; 246 break; 247 } else { 248 $this->connections[] = "<span style=\"color: red\">[FAILED] [".strtoupper($type)."] {$singleConnection['username']}@{$singleConnection['hostname']}</span>"; 249 } 250 } 251 } 252 253 // No write server was specified (simple connection or just multiple servers) - mirror write link 254 if (empty($connections['write'])) { 255 $this->write_link = $this->read_link; 256 } 257 258 // Have no read connection? 259 if ($this->read_link === null) { 260 $this->error("[READ] Unable to connect to database server"); 261 return false; 262 } else if($this->write_link === null) { 263 $this->error("[WRITE] Unable to connect to database server"); 264 return false; 265 } 266 267 $this->database = $config['database']; 268 269 if (version_compare(PHP_VERSION, '5.3.6', '<') === true) { 270 // character set in DSN was ignored before PHP 5.3.6, so we must SET NAMES 271 $this->setCharacterSet($this->db_encoding); 272 } 273 274 $this->current_link = $this->read_link; 275 return true; 276 } 277 278 /** 279 * Parse a hostname and possible port combination. 280 * 281 * @param string $hostname The hostname string. Can be any of the following formats: 282 * - `127.0.0.1` - IPv4 address. 283 * - `[::1]` - IPv6 address. 284 * - `localhost` - hostname. 285 * - `127.0.0.1:3306` - IPv4 address and port combination. 286 * `[::1]:3306` - IPv6 address and port combination. 287 * - `localhost:3306` - hostname and port combination. 288 * 289 * @return array Array of host and port. 290 * 291 * @throws InvalidArgumentException Thrown if {@see $hostname} is an IPv6 address which lacks a closing square bracket. 292 */ 293 private static function parseHostname($hostname) 294 { 295 // first, check for an IPv6 address - IPv6 addresses always start with `[` 296 $openingSquareBracket = strpos($hostname, '['); 297 if ($openingSquareBracket === 0) { 298 // find ending `]` 299 $closingSquareBracket = strpos($hostname, ']', $openingSquareBracket); 300 301 if ($closingSquareBracket !== false) { 302 $portSeparator = strpos($hostname, ':', $closingSquareBracket); 303 304 // there is no port specified 305 if ($portSeparator === false) { 306 return array($hostname, null); 307 } else { 308 $host = substr($hostname, $openingSquareBracket, $closingSquareBracket + 1); 309 $port = (int) substr($hostname, $portSeparator + 1); 310 311 return array($host, $port); 312 } 313 } else { 314 throw new InvalidArgumentException("Hostname is missing a closing square bracket for IPv6 address: {$hostname}"); 315 } 316 } 317 318 // either an IPv4 address or a hostname 319 $portSeparator = strpos($hostname, ':', 0); 320 if ($portSeparator === false) { 321 return array($hostname, null); 322 } else { 323 $host = substr($hostname, 0, $portSeparator); 324 $port = (int) substr($hostname, $portSeparator + 1); 325 326 return array($host, $port); 327 } 328 } 329 330 /** 331 * Set the character set to use. This issues a `SET NAMES` query to both the read and write links. 332 * 333 * @param string $characterSet The character set to use. 334 * 335 * @return void 336 */ 337 public function setCharacterSet($characterSet) 338 { 339 $query = "SET NAMES '{$characterSet}'"; 340 341 self::execIgnoreError($this->read_link, $query); 342 343 if ($this->write_link !== $this->read_link) { 344 self::execIgnoreError($this->write_link, $query); 345 } 346 } 347 348 /** 349 * Execute a query, ignoring any errors. 350 * 351 * @param PDO $connection The connection to execute the query on. 352 * @param string $query The query to execute. 353 */ 354 private static function execIgnoreError($connection, $query) 355 { 356 try { 357 $connection->exec($query); 358 } catch (PDOException $e) { 359 // ignored on purpose 360 } 361 } 362 363 /** 364 * Output a database error. 365 * 366 * @param string $string The string to present as an error. 367 * 368 * @return bool Whether error reporting is enabled or not 369 */ 370 public function error($string = '') 371 { 372 if ($this->error_reporting) { 373 if (class_exists("errorHandler")) { 374 global $error_handler; 375 376 if(!is_object($error_handler)) 377 { 378 require_once MYBB_ROOT."inc/class_error.php"; 379 $error_handler = new errorHandler(); 380 } 381 382 $error = array( 383 "error_no" => $this->error_number(), 384 "error" => $this->error_string(), 385 "query" => $string 386 ); 387 388 $error_handler->error(MYBB_SQL, $error); 389 } else { 390 trigger_error("<strong>[SQL] [". $this->error_number() ."]" . $this->error_string() . " </strong><br />{$string}", E_USER_ERROR); 391 } 392 393 return true; 394 } else { 395 return false; 396 } 397 } 398 399 /** 400 * Return the error code for the last error that occurred. 401 * 402 * @return string|null The error code for the last error that occurred, or null if no error occurred. 403 */ 404 public function error_number() 405 { 406 if ($this->lastPdoException !== null) { 407 return $this->lastPdoException->getCode(); 408 } 409 410 return null; 411 } 412 413 /** 414 * Return athe error message for the last error that occurred. 415 * 416 * @return string|null The error message for the last error that occurred, or null if no error occurred. 417 */ 418 public function error_string() 419 { 420 if ($this->lastPdoException !== null && isset($this->lastPdoException->errorInfo[2])) { 421 return $this->lastPdoException->errorInfo[2]; 422 } 423 424 return null; 425 } 426 427 /** 428 * Query the database. 429 * 430 * @param string $string The query SQL. 431 * @param boolean|int $hideErrors Whether to hide any errors that occur. 432 * @param boolean|int $writeQuery Whether to run the query on the write connection rather than the read connection. 433 * 434 * @return PDOStatement|null The result of the query, or null if an error occurred and {@see $hideErrors} was set. 435 */ 436 public function query($string, $hideErrors = false, $writeQuery = false) 437 { 438 global $mybb; 439 440 get_execution_time(); 441 442 // Only execute write queries on master server 443 if (($writeQuery || $this->last_query_type) && $this->write_link) { 444 $this->current_link = &$this->write_link; 445 } else { 446 $this->current_link = &$this->read_link; 447 } 448 449 /** @var PDOStatement|null $query */ 450 $query = null; 451 452 try { 453 if (preg_match('/^\\s*SELECT\\b/i', $string) === 1) { 454 // NOTE: we use prepare + execute here rather than just query so that we may request a scrollable cursor... 455 $query = $this->current_link->prepare($string, array(PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL)); 456 $query->execute(); 457 $this->lastPdoException = null; 458 } else { 459 $query = $this->current_link->query($string); 460 $this->lastPdoException = null; 461 } 462 } catch (PDOException $e) { 463 $this->lastPdoException = $e; 464 $query = null; 465 466 if (!$hideErrors) { 467 $this->error($string); 468 exit; 469 } 470 } 471 472 if ($writeQuery) { 473 $this->last_query_type = 1; 474 } else { 475 $this->last_query_type = 0; 476 } 477 478 $query_time = get_execution_time(); 479 $this->query_time += $query_time; 480 $this->query_count++; 481 $this->lastResult = $query; 482 483 if ($mybb->debug_mode) { 484 $this->explain_query($string, $query_time); 485 } 486 487 return $query; 488 } 489 490 /** 491 * Execute a write query on the master database 492 * 493 * @param string $query The query SQL. 494 * @param boolean|int $hideErrors Whether to hide any errors that occur. 495 * 496 * @return PDOStatement|null The result of the query, or null if an error occurred and {@see $hideErrors} was set. 497 */ 498 public function write_query($query, $hideErrors = false) 499 { 500 return $this->query($query, $hideErrors, true); 501 } 502 503 /** 504 * Return a result array for a query. 505 * 506 * @param PDOStatement $query The query to retrieve a result for. 507 * @param int $resultType The type of array to return. Can be any of the following values: 508 * - {@see PDO::FETCH_ASSOC} Fetch an array of results keyed by column name. This is the default. 509 * - {@see PDO::FETCH_NUM} Fetch an array of results keyed by column number, starting at 0. 510 * - {@see PDO::FETCH_BOTH} Fetch an array of results keyed by both column name and number. 511 * 512 * @return array|bool The array of results, or false if there are no more results. 513 */ 514 public function fetch_array($query, $resultType = PDO::FETCH_ASSOC) 515 { 516 if (is_null($query) || !($query instanceof PDOStatement)) { 517 return false; 518 } 519 520 switch($resultType) 521 { 522 case PDO::FETCH_NUM: 523 case PDO::FETCH_BOTH: 524 break; 525 default: 526 $resultType = PDO::FETCH_ASSOC; 527 break; 528 } 529 530 $hash = spl_object_hash($query); 531 532 if (isset($this->resultSeekPositions[$hash])) { 533 return $query->fetch($resultType, PDO::FETCH_ORI_ABS, $this->resultSeekPositions[$hash]); 534 } 535 536 return $query->fetch($resultType); 537 } 538 539 /** 540 * Return a specific field from a query. 541 * 542 * @param PDOStatement $query The query to retrieve a result for. 543 * @param string $field The name of the field to return. 544 * @param int|bool $row The number of the row to fetch it from, or false to fetch from the next row in the result set. 545 * 546 * @return mixed The resulting field, of false if no more rows are in th result set. 547 * Note that when querying fields that have a boolean value, this method should not be used. 548 */ 549 public function fetch_field($query, $field, $row = false) 550 { 551 if (is_null($query) || !($query instanceof PDOStatement)) { 552 return false; 553 } 554 555 if ($row !== false) { 556 $this->data_seek($query, (int) $row); 557 } 558 559 // NOTE: PDOStatement::fetchColumn only operates on numbered columns, so we must fetch the array result 560 $array = $this->fetch_array($query, PDO::FETCH_ASSOC); 561 562 if ($array === false) { 563 return false; 564 } 565 566 return $array[$field]; 567 } 568 569 /** 570 * Move the internal row pointer to the specified row. 571 * 572 * @param PDOStatement $query The query to move the row pointer for. 573 * @param int $row The row to move to. Rows are numbered from 0. 574 * 575 * @return bool Whether seeking was successful. 576 */ 577 public function data_seek($query, $row) 578 { 579 if (is_null($query) || !($query instanceof PDOStatement)) { 580 return false; 581 } 582 583 $hash = spl_object_hash($query); 584 585 // NOTE: PDO numbers rows from 1, but all other drivers are 0 based. We add 1 to the row number for compatibility 586 $this->resultSeekPositions[$hash] = ((int) $row) + 1; 587 588 return true; 589 } 590 591 /** 592 * Return the number of rows resulting from a query. 593 * 594 * @param PDOStatement $query The query data. 595 * @return int|bool The number of rows in the result, or false on failure. 596 */ 597 public function num_rows($query) 598 { 599 if (is_null($query) || !($query instanceof PDOStatement)) { 600 return false; 601 } 602 603 if (preg_match('/^\\s*SELECT\\b/i', $query->queryString) === 1) { 604 // rowCount does not return the number of rows in a select query on most DBMS, so we instead fetch all results then count them 605 // TODO: how do we handle the case where we issued a prepared statement with parameters..? 606 $countQuery = $this->read_link->query($query->queryString); 607 $result = $countQuery->fetchAll(PDO::FETCH_COLUMN, 0); 608 609 return count($result); 610 } else { 611 return $query->rowCount(); 612 } 613 } 614 615 /** 616 * Return the last id number of inserted data. 617 * 618 * @return string The id number. 619 */ 620 public function insert_id() 621 { 622 return $this->current_link->lastInsertId(); 623 } 624 625 /** 626 * Close the connection with the DBMS. 627 */ 628 public function close() 629 { 630 $this->read_link = $this->write_link = $this->current_link = null; 631 } 632 633 /** 634 * Returns the number of affected rows in a query. 635 * 636 * @return int The number of affected rows. 637 */ 638 public function affected_rows() 639 { 640 if ($this->lastResult === null) { 641 return 0; 642 } 643 644 return $this->lastResult->rowCount(); 645 } 646 647 /** 648 * Return the number of fields. 649 * 650 * @param PDOStatement $query The query result to get the number of fields for. 651 * 652 * @return int|bool The number of fields, or false if the number of fields could not be retrieved. 653 */ 654 public function num_fields($query) 655 { 656 if (is_null($query) || !($query instanceof PDOStatement)) { 657 return false; 658 } 659 660 return $query->columnCount(); 661 } 662 663 public function shutdown_query($query, $name = '') 664 { 665 global $shutdown_queries; 666 667 if($name) { 668 $shutdown_queries[$name] = $query; 669 } else { 670 $shutdown_queries[] = $query; 671 } 672 } 673 674 public function escape_string($string) 675 { 676 $string = $this->read_link->quote($string); 677 678 // Remove ' from the beginning of the string and at the end of the string, because we already quote parameters 679 $string = substr($string, 1); 680 $string = substr($string, 0, -1); 681 682 return $string; 683 } 684 685 public function free_result($query) 686 { 687 if (is_object($query) && $query instanceof PDOStatement) { 688 return $query->closeCursor(); 689 } 690 691 return false; 692 } 693 694 public function escape_string_like($string) 695 { 696 return $this->escape_string(str_replace(array('\\', '%', '_') , array('\\\\', '\\%' , '\\_') , $string)); 697 } 698 699 public function get_version() 700 { 701 if ($this->version) { 702 return $this->version; 703 } 704 705 $this->version = $this->read_link->getAttribute(PDO::ATTR_SERVER_VERSION); 706 707 return $this->version; 708 } 709 710 public function set_table_prefix($prefix) 711 { 712 $this->table_prefix = $prefix; 713 } 714 715 public function get_execution_time() 716 { 717 return get_execution_time(); 718 } 719 } 720