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