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