Database.php 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907
  1. <?php declare(strict_types=1);
  2. /**
  3. * PrivateBin
  4. *
  5. * a zero-knowledge paste bin
  6. *
  7. * @link https://github.com/PrivateBin/PrivateBin
  8. * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
  9. * @license https://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
  10. */
  11. namespace PrivateBin\Data;
  12. use Exception;
  13. use JsonException;
  14. use PDO;
  15. use PDOException;
  16. use PrivateBin\Controller;
  17. use PrivateBin\Json;
  18. /**
  19. * Database
  20. *
  21. * Model for database access, implemented as a singleton.
  22. */
  23. class Database extends AbstractData
  24. {
  25. /**
  26. * instance of database connection
  27. *
  28. * @access private
  29. * @var PDO
  30. */
  31. private $_db;
  32. /**
  33. * table prefix
  34. *
  35. * @access private
  36. * @var string
  37. */
  38. private $_prefix = '';
  39. /**
  40. * database type
  41. *
  42. * @access private
  43. * @var string
  44. */
  45. private $_type = '';
  46. /**
  47. * instantiates a new Database data backend
  48. *
  49. * @access public
  50. * @param array $options
  51. * @throws Exception
  52. */
  53. public function __construct(array $options)
  54. {
  55. // set table prefix if given
  56. if (array_key_exists('tbl', $options)) {
  57. $this->_prefix = $options['tbl'];
  58. }
  59. // initialize the db connection with new options
  60. if (
  61. array_key_exists('dsn', $options) &&
  62. array_key_exists('usr', $options) &&
  63. array_key_exists('pwd', $options) &&
  64. array_key_exists('opt', $options)
  65. ) {
  66. // set default options
  67. $options['opt'][PDO::ATTR_ERRMODE] = PDO::ERRMODE_EXCEPTION;
  68. $options['opt'][PDO::ATTR_EMULATE_PREPARES] = false;
  69. if (!array_key_exists(PDO::ATTR_PERSISTENT, $options['opt'])) {
  70. $options['opt'][PDO::ATTR_PERSISTENT] = true;
  71. }
  72. $db_tables_exist = true;
  73. // setup type and dabase connection
  74. $this->_type = strtolower(
  75. substr($options['dsn'], 0, strpos($options['dsn'], ':'))
  76. );
  77. // MySQL uses backticks to quote identifiers by default,
  78. // tell it to expect ANSI SQL double quotes
  79. if ($this->_type === 'mysql') {
  80. // deprecated as of PHP 8.5
  81. if (version_compare(PHP_VERSION, '8.5') < 0 && defined('PDO::MYSQL_ATTR_INIT_COMMAND')) {
  82. $options['opt'][PDO::MYSQL_ATTR_INIT_COMMAND] = "SET SESSION sql_mode='ANSI_QUOTES'";
  83. } elseif (defined('Pdo\Mysql::ATTR_INIT_COMMAND')) {
  84. $options['opt'][Pdo\Mysql::ATTR_INIT_COMMAND] = "SET SESSION sql_mode='ANSI_QUOTES'";
  85. }
  86. }
  87. $tableQuery = $this->_getTableQuery($this->_type);
  88. $this->_db = new PDO(
  89. $options['dsn'],
  90. $options['usr'],
  91. $options['pwd'],
  92. $options['opt']
  93. );
  94. // check if the database contains the required tables
  95. $tables = $this->_db->query($tableQuery)->fetchAll(PDO::FETCH_COLUMN, 0);
  96. // create paste table if necessary
  97. if (!in_array($this->_sanitizeIdentifier('paste'), $tables)) {
  98. $this->_createPasteTable();
  99. $db_tables_exist = false;
  100. }
  101. // create comment table if necessary
  102. if (!in_array($this->_sanitizeIdentifier('comment'), $tables)) {
  103. $this->_createCommentTable();
  104. $db_tables_exist = false;
  105. }
  106. // create config table if necessary
  107. $db_version = Controller::VERSION;
  108. if (!in_array($this->_sanitizeIdentifier('config'), $tables)) {
  109. $this->_createConfigTable();
  110. // if we only needed to create the config table, the DB is older then 0.22
  111. if ($db_tables_exist) {
  112. $db_version = '0.21';
  113. }
  114. } else {
  115. $db_version = $this->_getConfig('VERSION');
  116. }
  117. // update database structure if necessary
  118. if (version_compare($db_version, Controller::VERSION, '<')) {
  119. $this->_upgradeDatabase($db_version);
  120. }
  121. } else {
  122. throw new Exception(
  123. 'Missing configuration for key dsn, usr, pwd or opt in the section model_options, please check your configuration file', 6
  124. );
  125. }
  126. }
  127. /**
  128. * Create a paste.
  129. *
  130. * @access public
  131. * @param string $pasteid
  132. * @param array $paste
  133. * @return bool
  134. */
  135. public function create($pasteid, array &$paste)
  136. {
  137. $expire_date = 0;
  138. $meta = $paste['meta'];
  139. if (array_key_exists('expire_date', $meta)) {
  140. $expire_date = (int) $meta['expire_date'];
  141. unset($meta['expire_date']);
  142. }
  143. try {
  144. return $this->_exec(
  145. 'INSERT INTO "' . $this->_sanitizeIdentifier('paste') .
  146. '" VALUES(?,?,?,?)',
  147. [
  148. $pasteid,
  149. Json::encode($paste),
  150. $expire_date,
  151. Json::encode($meta),
  152. ]
  153. );
  154. } catch (Exception $e) {
  155. error_log('Error while attempting to insert a paste into the database: ' . $e->getMessage());
  156. return false;
  157. }
  158. }
  159. /**
  160. * Read a paste.
  161. *
  162. * @access public
  163. * @param string $pasteid
  164. * @return array|false
  165. */
  166. public function read($pasteid)
  167. {
  168. try {
  169. $row = $this->_select(
  170. 'SELECT * FROM "' . $this->_sanitizeIdentifier('paste') .
  171. '" WHERE "dataid" = ?', [$pasteid], true
  172. );
  173. } catch (PDOException $e) {
  174. $row = false;
  175. }
  176. if ($row === false) {
  177. return false;
  178. }
  179. // create array
  180. try {
  181. $paste = Json::decode($row['data']);
  182. } catch (JsonException $e) {
  183. error_log('Error while reading a paste from the database: ' . $e->getMessage());
  184. $paste = [];
  185. }
  186. try {
  187. $paste['meta'] = Json::decode($row['meta']);
  188. } catch (JsonException $e) {
  189. error_log('Error while reading a paste from the database: ' . $e->getMessage());
  190. $paste['meta'] = [];
  191. }
  192. $expire_date = (int) $row['expiredate'];
  193. if ($expire_date > 0) {
  194. $paste['meta']['expire_date'] = $expire_date;
  195. }
  196. return $paste;
  197. }
  198. /**
  199. * Delete a paste and its discussion.
  200. *
  201. * @access public
  202. * @param string $pasteid
  203. */
  204. public function delete($pasteid)
  205. {
  206. $this->_exec(
  207. 'DELETE FROM "' . $this->_sanitizeIdentifier('paste') .
  208. '" WHERE "dataid" = ?', [$pasteid]
  209. );
  210. $this->_exec(
  211. 'DELETE FROM "' . $this->_sanitizeIdentifier('comment') .
  212. '" WHERE "pasteid" = ?', [$pasteid]
  213. );
  214. }
  215. /**
  216. * Test if a paste exists.
  217. *
  218. * @access public
  219. * @param string $pasteid
  220. * @return bool
  221. */
  222. public function exists($pasteid)
  223. {
  224. try {
  225. $row = $this->_select(
  226. 'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('paste') .
  227. '" WHERE "dataid" = ?', [$pasteid], true
  228. );
  229. } catch (PDOException $e) {
  230. return false;
  231. }
  232. return (bool) $row;
  233. }
  234. /**
  235. * Create a comment in a paste.
  236. *
  237. * @access public
  238. * @param string $pasteid
  239. * @param string $parentid
  240. * @param string $commentid
  241. * @param array $comment
  242. * @return bool
  243. */
  244. public function createComment($pasteid, $parentid, $commentid, array &$comment)
  245. {
  246. try {
  247. $data = Json::encode($comment);
  248. } catch (JsonException $e) {
  249. error_log('Error while attempting to insert a comment into the database: ' . $e->getMessage());
  250. return false;
  251. }
  252. $meta = $comment['meta'];
  253. if (!array_key_exists('icon', $meta)) {
  254. $meta['icon'] = null;
  255. }
  256. try {
  257. return $this->_exec(
  258. 'INSERT INTO "' . $this->_sanitizeIdentifier('comment') .
  259. '" VALUES(?,?,?,?,?,?)',
  260. [
  261. $commentid,
  262. $pasteid,
  263. $parentid,
  264. $data,
  265. $meta['icon'],
  266. $meta['created'],
  267. ]
  268. );
  269. } catch (PDOException $e) {
  270. error_log('Error while attempting to insert a comment into the database: ' . $e->getMessage());
  271. return false;
  272. }
  273. }
  274. /**
  275. * Read all comments of paste.
  276. *
  277. * @access public
  278. * @param string $pasteid
  279. * @return array
  280. */
  281. public function readComments($pasteid)
  282. {
  283. $rows = $this->_select(
  284. 'SELECT * FROM "' . $this->_sanitizeIdentifier('comment') .
  285. '" WHERE "pasteid" = ?', [$pasteid]
  286. );
  287. // create comment list
  288. $comments = [];
  289. if (count($rows)) {
  290. foreach ($rows as $row) {
  291. try {
  292. $data = Json::decode($row['data']);
  293. } catch (JsonException $e) {
  294. error_log('Error while reading a comment from the database: ' . $e->getMessage());
  295. $data = [];
  296. }
  297. $i = $this->getOpenSlot($comments, (int) $row['postdate']);
  298. $comments[$i] = $data;
  299. $comments[$i]['id'] = $row['dataid'];
  300. $comments[$i]['parentid'] = $row['parentid'];
  301. $comments[$i]['meta'] = ['created' => (int) $row['postdate']];
  302. if (array_key_exists('vizhash', $row) && !empty($row['vizhash'])) {
  303. $comments[$i]['meta']['icon'] = $row['vizhash'];
  304. }
  305. }
  306. }
  307. return $this->sortComments($comments);
  308. }
  309. /**
  310. * Test if a comment exists.
  311. *
  312. * @access public
  313. * @param string $pasteid
  314. * @param string $parentid
  315. * @param string $commentid
  316. * @return bool
  317. */
  318. public function existsComment($pasteid, $parentid, $commentid)
  319. {
  320. try {
  321. return (bool) $this->_select(
  322. 'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('comment') .
  323. '" WHERE "pasteid" = ? AND "parentid" = ? AND "dataid" = ?',
  324. [$pasteid, $parentid, $commentid], true
  325. );
  326. } catch (PDOException $e) {
  327. return false;
  328. }
  329. }
  330. /**
  331. * Save a value.
  332. *
  333. * @access public
  334. * @param string $value
  335. * @param string $namespace
  336. * @param string $key
  337. * @return bool
  338. */
  339. public function setValue($value, $namespace, $key = '')
  340. {
  341. if ($namespace === 'traffic_limiter') {
  342. $this->_last_cache[$key] = $value;
  343. try {
  344. $value = Json::encode($this->_last_cache);
  345. } catch (JsonException $e) {
  346. error_log('Error encoding JSON for table "config", row "traffic_limiter": ' . $e->getMessage());
  347. return false;
  348. }
  349. }
  350. return $this->_exec(
  351. 'UPDATE "' . $this->_sanitizeIdentifier('config') .
  352. '" SET "value" = ? WHERE "id" = ?',
  353. [$value, strtoupper($namespace)]
  354. );
  355. }
  356. /**
  357. * Load a value.
  358. *
  359. * @access public
  360. * @param string $namespace
  361. * @param string $key
  362. * @return string
  363. */
  364. public function getValue($namespace, $key = '')
  365. {
  366. $configKey = strtoupper($namespace);
  367. $value = $this->_getConfig($configKey);
  368. if ($value === '') {
  369. // initialize the row, so that setValue can rely on UPDATE queries
  370. $this->_exec(
  371. 'INSERT INTO "' . $this->_sanitizeIdentifier('config') .
  372. '" VALUES(?,?)',
  373. [$configKey, '']
  374. );
  375. // migrate filesystem based salt into database
  376. $file = 'data' . DIRECTORY_SEPARATOR . 'salt.php';
  377. if ($namespace === 'salt' && is_readable($file)) {
  378. $fs = new Filesystem(['dir' => 'data']);
  379. $value = $fs->getValue('salt');
  380. $this->setValue($value, 'salt');
  381. if (!unlink($file)) {
  382. error_log('Error deleting migrated salt: ' . $file);
  383. }
  384. return $value;
  385. }
  386. }
  387. if ($value && $namespace === 'traffic_limiter') {
  388. try {
  389. $this->_last_cache = Json::decode($value);
  390. } catch (JsonException $e) {
  391. error_log('Error decoding JSON from table "config", row "traffic_limiter": ' . $e->getMessage());
  392. $this->_last_cache = [];
  393. }
  394. if (array_key_exists($key, $this->_last_cache)) {
  395. return $this->_last_cache[$key];
  396. }
  397. }
  398. return (string) $value;
  399. }
  400. /**
  401. * Returns up to batch size number of paste ids that have expired
  402. *
  403. * @access private
  404. * @param int $batchsize
  405. * @return array
  406. */
  407. protected function _getExpiredPastes($batchsize)
  408. {
  409. try {
  410. $statement = $this->_db->prepare(
  411. 'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('paste') .
  412. '" WHERE "expiredate" < ? AND "expiredate" != ? ' .
  413. ($this->_type === 'oci' ? 'FETCH NEXT ? ROWS ONLY' : 'LIMIT ?')
  414. );
  415. $statement->execute([time(), 0, $batchsize]);
  416. return $statement->fetchAll(PDO::FETCH_COLUMN, 0);
  417. } catch (PDOException $e) {
  418. error_log('Error while attempting to find expired pastes in the database: ' . $e->getMessage());
  419. return [];
  420. }
  421. }
  422. /**
  423. * @inheritDoc
  424. */
  425. public function getAllPastes()
  426. {
  427. return $this->_db->query(
  428. 'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('paste') . '"'
  429. )->fetchAll(PDO::FETCH_COLUMN, 0);
  430. }
  431. /**
  432. * execute a statement
  433. *
  434. * @access private
  435. * @param string $sql
  436. * @param array $params
  437. * @throws PDOException
  438. * @return bool
  439. */
  440. private function _exec($sql, array $params)
  441. {
  442. $statement = $this->_db->prepare($sql);
  443. $position = 1;
  444. foreach ($params as &$parameter) {
  445. if (is_int($parameter)) {
  446. $statement->bindParam($position, $parameter, PDO::PARAM_INT);
  447. } elseif (is_string($parameter) && strlen($parameter) >= 4000) {
  448. $statement->bindParam($position, $parameter, PDO::PARAM_STR, strlen($parameter));
  449. } else {
  450. $statement->bindParam($position, $parameter);
  451. }
  452. ++$position;
  453. }
  454. $result = $statement->execute();
  455. $statement->closeCursor();
  456. return $result;
  457. }
  458. /**
  459. * run a select statement
  460. *
  461. * @access private
  462. * @param string $sql
  463. * @param array $params
  464. * @param bool $firstOnly if only the first row should be returned
  465. * @throws PDOException
  466. * @return array
  467. */
  468. private function _select($sql, array $params, $firstOnly = false)
  469. {
  470. $statement = $this->_db->prepare($sql);
  471. $statement->execute($params);
  472. if ($firstOnly) {
  473. $result = $statement->fetch(PDO::FETCH_ASSOC);
  474. if ($this->_type === 'oci' && is_array($result)) {
  475. // returned CLOB values are streams, convert these into strings
  476. $result = array_map('PrivateBin\Data\Database::_sanitizeClob', $result);
  477. }
  478. } elseif ($this->_type === 'oci') {
  479. // workaround for https://bugs.php.net/bug.php?id=46728
  480. $result = [];
  481. while ($row = $statement->fetch(PDO::FETCH_ASSOC)) {
  482. $result[] = array_map('PrivateBin\Data\Database::_sanitizeClob', $row);
  483. }
  484. } else {
  485. $result = $statement->fetchAll(PDO::FETCH_ASSOC);
  486. }
  487. $statement->closeCursor();
  488. return $result;
  489. }
  490. /**
  491. * get table list query, depending on the database type
  492. *
  493. * @access private
  494. * @param string $type
  495. * @throws Exception
  496. * @return string
  497. */
  498. private function _getTableQuery($type)
  499. {
  500. switch ($type) {
  501. case 'ibm':
  502. $sql = 'SELECT "tabname" FROM "SYSCAT"."TABLES"';
  503. break;
  504. case 'informix':
  505. $sql = 'SELECT "tabname" FROM "systables"';
  506. break;
  507. case 'mssql':
  508. // U: tables created by the user
  509. $sql = 'SELECT "name" FROM "sysobjects" '
  510. . 'WHERE "type" = \'U\' ORDER BY "name"';
  511. break;
  512. case 'mysql':
  513. $sql = 'SHOW TABLES';
  514. break;
  515. case 'oci':
  516. $sql = 'SELECT table_name FROM all_tables';
  517. break;
  518. case 'pgsql':
  519. $sql = 'SELECT "tablename" FROM "pg_catalog"."pg_tables" '
  520. . 'WHERE "schemaname" NOT IN (\'pg_catalog\', \'information_schema\')';
  521. break;
  522. case 'sqlite':
  523. $sql = 'SELECT "name" FROM "sqlite_master" WHERE "type"=\'table\' '
  524. . 'UNION ALL SELECT "name" FROM "sqlite_temp_master" '
  525. . 'WHERE "type"=\'table\' ORDER BY "name"';
  526. break;
  527. default:
  528. throw new Exception(
  529. "PDO type $type is currently not supported.", 5
  530. );
  531. }
  532. return $sql;
  533. }
  534. /**
  535. * get a value by key from the config table
  536. *
  537. * @access private
  538. * @param string $key
  539. * @return string
  540. */
  541. private function _getConfig($key)
  542. {
  543. try {
  544. $row = $this->_select(
  545. 'SELECT "value" FROM "' . $this->_sanitizeIdentifier('config') .
  546. '" WHERE "id" = ?', [$key], true
  547. );
  548. } catch (PDOException $e) {
  549. error_log('Error while attempting to fetch configuration key "' . $key . '" in the database: ' . $e->getMessage());
  550. return '';
  551. }
  552. return $row ? $row['value'] : '';
  553. }
  554. /**
  555. * get the primary key clauses, depending on the database driver
  556. *
  557. * @access private
  558. * @param string $key
  559. * @return array
  560. */
  561. private function _getPrimaryKeyClauses($key = 'dataid')
  562. {
  563. $main_key = $after_key = '';
  564. switch ($this->_type) {
  565. case 'mysql':
  566. case 'oci':
  567. $after_key = ", PRIMARY KEY (\"$key\")";
  568. break;
  569. default:
  570. $main_key = ' PRIMARY KEY';
  571. break;
  572. }
  573. return [$main_key, $after_key];
  574. }
  575. /**
  576. * get the data type, depending on the database driver
  577. *
  578. * PostgreSQL and OCI uses a different API for BLOBs then SQL, hence we use TEXT and CLOB
  579. *
  580. * @access private
  581. * @return string
  582. */
  583. private function _getDataType()
  584. {
  585. switch ($this->_type) {
  586. case 'oci':
  587. return 'CLOB';
  588. case 'pgsql':
  589. return 'TEXT';
  590. default:
  591. return 'BLOB';
  592. }
  593. }
  594. /**
  595. * get the attachment type, depending on the database driver
  596. *
  597. * PostgreSQL and OCI use different APIs for BLOBs then SQL, hence we use TEXT and CLOB
  598. *
  599. * @access private
  600. * @return string
  601. */
  602. private function _getAttachmentType()
  603. {
  604. switch ($this->_type) {
  605. case 'oci':
  606. return 'CLOB';
  607. case 'pgsql':
  608. return 'TEXT';
  609. default:
  610. return 'MEDIUMBLOB';
  611. }
  612. }
  613. /**
  614. * get the meta type, depending on the database driver
  615. *
  616. * OCI doesn't accept TEXT so it has to be VARCHAR2(4000)
  617. *
  618. * @access private
  619. * @return string
  620. */
  621. private function _getMetaType()
  622. {
  623. switch ($this->_type) {
  624. case 'oci':
  625. return 'VARCHAR2(4000)';
  626. default:
  627. return 'TEXT';
  628. }
  629. }
  630. /**
  631. * create the paste table
  632. *
  633. * @access private
  634. */
  635. private function _createPasteTable()
  636. {
  637. list($main_key, $after_key) = $this->_getPrimaryKeyClauses();
  638. $attachmentType = $this->_getAttachmentType();
  639. $metaType = $this->_getMetaType();
  640. $this->_db->exec(
  641. 'CREATE TABLE "' . $this->_sanitizeIdentifier('paste') . '" ( ' .
  642. "\"dataid\" CHAR(16) NOT NULL$main_key, " .
  643. "\"data\" $attachmentType, " .
  644. '"expiredate" INT, ' .
  645. "\"meta\" $metaType$after_key )"
  646. );
  647. }
  648. /**
  649. * create the comment table
  650. *
  651. * @access private
  652. */
  653. private function _createCommentTable()
  654. {
  655. list($main_key, $after_key) = $this->_getPrimaryKeyClauses();
  656. $dataType = $this->_getDataType();
  657. $this->_db->exec(
  658. 'CREATE TABLE "' . $this->_sanitizeIdentifier('comment') . '" ( ' .
  659. "\"dataid\" CHAR(16) NOT NULL$main_key, " .
  660. '"pasteid" CHAR(16), ' .
  661. '"parentid" CHAR(16), ' .
  662. "\"data\" $dataType, " .
  663. "\"vizhash\" $dataType, " .
  664. "\"postdate\" INT$after_key )"
  665. );
  666. if ($this->_type === 'oci') {
  667. $this->_db->exec(
  668. 'declare
  669. already_exists exception;
  670. columns_indexed exception;
  671. pragma exception_init( already_exists, -955 );
  672. pragma exception_init(columns_indexed, -1408);
  673. begin
  674. execute immediate \'create index "comment_parent" on "' . $this->_sanitizeIdentifier('comment') . '" ("pasteid")\';
  675. exception
  676. when already_exists or columns_indexed then
  677. NULL;
  678. end;'
  679. );
  680. } else {
  681. // CREATE INDEX IF NOT EXISTS not supported as of Oracle MySQL <= 8.0
  682. $this->_db->exec(
  683. 'CREATE INDEX "' .
  684. $this->_sanitizeIdentifier('comment_parent') . '" ON "' .
  685. $this->_sanitizeIdentifier('comment') . '" ("pasteid")'
  686. );
  687. }
  688. }
  689. /**
  690. * create the config table
  691. *
  692. * @access private
  693. */
  694. private function _createConfigTable()
  695. {
  696. list($main_key, $after_key) = $this->_getPrimaryKeyClauses('id');
  697. $charType = $this->_type === 'oci' ? 'VARCHAR2(16)' : 'CHAR(16)';
  698. $textType = $this->_getMetaType();
  699. $this->_db->exec(
  700. 'CREATE TABLE "' . $this->_sanitizeIdentifier('config') .
  701. "\" ( \"id\" $charType NOT NULL$main_key, \"value\" $textType$after_key )"
  702. );
  703. $this->_exec(
  704. 'INSERT INTO "' . $this->_sanitizeIdentifier('config') .
  705. '" VALUES(?,?)',
  706. ['VERSION', Controller::VERSION]
  707. );
  708. }
  709. /**
  710. * sanitizes CLOB values used with OCI
  711. *
  712. * From: https://stackoverflow.com/questions/36200534/pdo-oci-into-a-clob-field
  713. *
  714. * @access public
  715. * @static
  716. * @param int|string|resource $value
  717. * @return int|string
  718. */
  719. public static function _sanitizeClob($value)
  720. {
  721. if (is_resource($value)) {
  722. $value = stream_get_contents($value);
  723. }
  724. return $value;
  725. }
  726. /**
  727. * sanitizes identifiers
  728. *
  729. * @access private
  730. * @param string $identifier
  731. * @return string
  732. */
  733. private function _sanitizeIdentifier($identifier)
  734. {
  735. return preg_replace('/[^A-Za-z0-9_]+/', '', $this->_prefix . $identifier);
  736. }
  737. /**
  738. * check if the current database type supports dropping columns
  739. *
  740. * @access private
  741. * @return bool
  742. */
  743. private function _supportsDropColumn()
  744. {
  745. $supportsDropColumn = true;
  746. if ($this->_type === 'sqlite') {
  747. try {
  748. $row = $this->_select('SELECT sqlite_version() AS "v"', [], true);
  749. $supportsDropColumn = (bool) version_compare($row['v'], '3.35.0', '>=');
  750. } catch (PDOException $e) {
  751. $supportsDropColumn = false;
  752. }
  753. }
  754. return $supportsDropColumn;
  755. }
  756. /**
  757. * upgrade the database schema from an old version
  758. *
  759. * @access private
  760. * @param string $oldversion
  761. */
  762. private function _upgradeDatabase($oldversion)
  763. {
  764. $dataType = $this->_getDataType();
  765. $attachmentType = $this->_getAttachmentType();
  766. if (version_compare($oldversion, '0.21', '<=')) {
  767. // create the meta column if necessary (pre 0.21 change)
  768. try {
  769. $this->_db->exec(
  770. 'SELECT "meta" FROM "' . $this->_sanitizeIdentifier('paste') . '" ' .
  771. ($this->_type === 'oci' ? 'FETCH NEXT 1 ROWS ONLY' : 'LIMIT 1')
  772. );
  773. } catch (PDOException $e) {
  774. $this->_db->exec('ALTER TABLE "' . $this->_sanitizeIdentifier('paste') . '" ADD COLUMN "meta" TEXT');
  775. }
  776. // SQLite only allows one ALTER statement at a time...
  777. $this->_db->exec(
  778. 'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
  779. "\" ADD COLUMN \"attachment\" $attachmentType"
  780. );
  781. $this->_db->exec(
  782. 'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') . "\" ADD COLUMN \"attachmentname\" $dataType"
  783. );
  784. // SQLite doesn't support MODIFY, but it allows TEXT of similar
  785. // size as BLOB, so there is no need to change it there
  786. if ($this->_type !== 'sqlite') {
  787. $this->_db->exec(
  788. 'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
  789. "\" ADD PRIMARY KEY (\"dataid\"), MODIFY COLUMN \"data\" $dataType"
  790. );
  791. $this->_db->exec(
  792. 'ALTER TABLE "' . $this->_sanitizeIdentifier('comment') .
  793. "\" ADD PRIMARY KEY (\"dataid\"), MODIFY COLUMN \"data\" $dataType, " .
  794. "MODIFY COLUMN \"nickname\" $dataType, MODIFY COLUMN \"vizhash\" $dataType"
  795. );
  796. } else {
  797. $this->_db->exec(
  798. 'CREATE UNIQUE INDEX IF NOT EXISTS "' .
  799. $this->_sanitizeIdentifier('paste_dataid') . '" ON "' .
  800. $this->_sanitizeIdentifier('paste') . '" ("dataid")'
  801. );
  802. $this->_db->exec(
  803. 'CREATE UNIQUE INDEX IF NOT EXISTS "' .
  804. $this->_sanitizeIdentifier('comment_dataid') . '" ON "' .
  805. $this->_sanitizeIdentifier('comment') . '" ("dataid")'
  806. );
  807. }
  808. // CREATE INDEX IF NOT EXISTS not supported as of Oracle MySQL <= 8.0
  809. $this->_db->exec(
  810. 'CREATE INDEX "' .
  811. $this->_sanitizeIdentifier('comment_parent') . '" ON "' .
  812. $this->_sanitizeIdentifier('comment') . '" ("pasteid")'
  813. );
  814. }
  815. if (version_compare($oldversion, '1.3', '<=')) {
  816. // SQLite doesn't support MODIFY, but it allows TEXT of similar
  817. // size as BLOB and PostgreSQL uses TEXT, so there is no need
  818. // to change it there
  819. if ($this->_type !== 'sqlite' && $this->_type !== 'pgsql') {
  820. $this->_db->exec(
  821. 'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
  822. "\" MODIFY COLUMN \"data\" $attachmentType"
  823. );
  824. }
  825. }
  826. if (version_compare($oldversion, '1.7.1', '<=')) {
  827. if ($this->_supportsDropColumn()) {
  828. $this->_db->exec(
  829. 'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
  830. '" DROP COLUMN "postdate"'
  831. );
  832. }
  833. }
  834. if (version_compare($oldversion, '1.7.8', '<=')) {
  835. if ($this->_supportsDropColumn()) {
  836. $this->_db->exec(
  837. 'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
  838. '" DROP COLUMN "opendiscussion"'
  839. );
  840. $this->_db->exec(
  841. 'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
  842. '" DROP COLUMN "burnafterreading"'
  843. );
  844. $this->_db->exec(
  845. 'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
  846. '" DROP COLUMN "attachment"'
  847. );
  848. $this->_db->exec(
  849. 'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
  850. '" DROP COLUMN "attachmentname"'
  851. );
  852. $this->_db->exec(
  853. 'ALTER TABLE "' . $this->_sanitizeIdentifier('comment') .
  854. '" DROP COLUMN "nickname"'
  855. );
  856. }
  857. }
  858. $this->_exec(
  859. 'UPDATE "' . $this->_sanitizeIdentifier('config') .
  860. '" SET "value" = ? WHERE "id" = ?',
  861. [Controller::VERSION, 'VERSION']
  862. );
  863. }
  864. }