Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.93% covered (success)
84.93%
355 / 418
37.04% covered (danger)
37.04%
10 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Database
84.93% covered (success)
84.93%
355 / 418
37.04% covered (danger)
37.04%
10 / 27
165.67
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
86.36% covered (success)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
7.12
 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', '<') && 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        try {
305            $rows = $this->_select(
306                'SELECT * FROM "' . $this->_sanitizeIdentifier('comment') .
307                '" WHERE "pasteid" = ?', [$pasteid]
308            );
309        } catch (PDOException $e) {
310            error_log('Error while reading comments from the database: ' . $e->getMessage());
311            return [];
312        }
313
314        // create comment list
315        $comments = [];
316        if (count($rows)) {
317            foreach ($rows as $row) {
318                try {
319                    $data = Json::decode($row['data']);
320                } catch (JsonException $e) {
321                    error_log('Error while reading a comment from the database: ' . $e->getMessage());
322                    $data = [];
323                }
324                $i = $this->getOpenSlot($comments, (int) $row['postdate']);
325                $comments[$i] = $data;
326                $comments[$i]['id'] = $row['dataid'];
327                $comments[$i]['parentid'] = $row['parentid'];
328                $comments[$i]['meta'] = ['created' => (int) $row['postdate']];
329                if (array_key_exists('vizhash', $row) && !empty($row['vizhash'])) {
330                    $comments[$i]['meta']['icon'] = $row['vizhash'];
331                }
332            }
333        }
334        return $this->sortComments($comments);
335    }
336
337    /**
338     * Test if a comment exists.
339     *
340     * @access public
341     * @param  string $pasteid
342     * @param  string $parentid
343     * @param  string $commentid
344     * @return bool
345     */
346    public function existsComment($pasteid, $parentid, $commentid)
347    {
348        try {
349            return (bool) $this->_select(
350                'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('comment') .
351                '" WHERE "pasteid" = ? AND "parentid" = ? AND "dataid" = ?',
352                [$pasteid, $parentid, $commentid], true
353            );
354        } catch (PDOException $e) {
355            return false;
356        }
357    }
358
359    /**
360     * Save a value.
361     *
362     * @access public
363     * @param  string $value
364     * @param  string $namespace
365     * @param  string $key
366     * @return bool
367     */
368    public function setValue($value, $namespace, $key = '')
369    {
370        if ($namespace === 'traffic_limiter') {
371            $this->_last_cache[$key] = $value;
372            try {
373                $value = Json::encode($this->_last_cache);
374            } catch (JsonException $e) {
375                error_log('Error encoding JSON for table "config", row "traffic_limiter": ' . $e->getMessage());
376                return false;
377            }
378        }
379        return $this->_exec(
380            'UPDATE "' . $this->_sanitizeIdentifier('config') .
381            '" SET "value" = ? WHERE "id" = ?',
382            [$value, strtoupper($namespace)]
383        );
384    }
385
386    /**
387     * Load a value.
388     *
389     * @access public
390     * @param  string $namespace
391     * @param  string $key
392     * @return string
393     */
394    public function getValue($namespace, $key = '')
395    {
396        $configKey = strtoupper($namespace);
397        $value = $this->_getConfig($configKey);
398        if ($value === '') {
399            // initialize the row, so that setValue can rely on UPDATE queries
400            $this->_exec(
401                'INSERT INTO "' . $this->_sanitizeIdentifier('config') .
402                '" VALUES(?,?)',
403                [$configKey, '']
404            );
405
406            // migrate filesystem based salt into database
407            $file = 'data' . DIRECTORY_SEPARATOR . 'salt.php';
408            if ($namespace === 'salt' && is_readable($file)) {
409                $fs = new Filesystem(['dir' => 'data']);
410                $value = $fs->getValue('salt');
411                $this->setValue($value, 'salt');
412                if (!unlink($file)) {
413                    error_log('Error deleting migrated salt: ' . $file);
414                }
415                return $value;
416            }
417        }
418        if ($value && $namespace === 'traffic_limiter') {
419            try {
420                $this->_last_cache = Json::decode($value);
421            } catch (JsonException $e) {
422                error_log('Error decoding JSON from table "config", row "traffic_limiter": ' . $e->getMessage());
423                $this->_last_cache = [];
424            }
425            if (array_key_exists($key, $this->_last_cache)) {
426                return $this->_last_cache[$key];
427            }
428        }
429        return (string) $value;
430    }
431
432    /**
433     * Returns up to batch size number of paste ids that have expired
434     *
435     * @access private
436     * @param  int $batchsize
437     * @return array
438     */
439    protected function _getExpiredPastes($batchsize)
440    {
441        try {
442            $statement = $this->_db->prepare(
443                'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('paste') .
444                '" WHERE "expiredate" < ? AND "expiredate" != ? ' .
445                ($this->_type === 'oci' ? 'FETCH NEXT ? ROWS ONLY' : 'LIMIT ?')
446            );
447            $statement->execute([time(), 0, $batchsize]);
448            return $statement->fetchAll(PDO::FETCH_COLUMN, 0);
449        } catch (PDOException $e) {
450            error_log('Error while attempting to find expired pastes in the database: ' . $e->getMessage());
451            return [];
452        }
453    }
454
455    /**
456     * @inheritDoc
457     */
458    public function getAllPastes()
459    {
460        return $this->_db->query(
461            'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('paste') . '"'
462        )->fetchAll(PDO::FETCH_COLUMN, 0);
463    }
464
465    /**
466     * execute a statement
467     *
468     * @access private
469     * @param  string $sql
470     * @param  array $params
471     * @throws PDOException
472     * @return bool
473     */
474    private function _exec($sql, array $params)
475    {
476        $statement = $this->_db->prepare($sql);
477        $position = 1;
478        foreach ($params as &$parameter) {
479            if (is_int($parameter)) {
480                $statement->bindParam($position, $parameter, PDO::PARAM_INT);
481            } elseif (is_string($parameter) && strlen($parameter) >= 4000) {
482                $statement->bindParam($position, $parameter, PDO::PARAM_STR, strlen($parameter));
483            } else {
484                $statement->bindParam($position, $parameter);
485            }
486            ++$position;
487        }
488        $result = $statement->execute();
489        $statement->closeCursor();
490        return $result;
491    }
492
493    /**
494     * run a select statement
495     *
496     * @access private
497     * @param  string $sql
498     * @param  array $params
499     * @param  bool $firstOnly if only the first row should be returned
500     * @throws PDOException
501     * @return array
502     */
503    private function _select($sql, array $params, $firstOnly = false)
504    {
505        $statement = $this->_db->prepare($sql);
506        $statement->execute($params);
507        if ($firstOnly) {
508            $result = $statement->fetch(PDO::FETCH_ASSOC);
509            if ($this->_type === 'oci' && is_array($result)) {
510                // returned CLOB values are streams, convert these into strings
511                $result = array_map('PrivateBin\Data\Database::_sanitizeClob', $result);
512            }
513        } elseif ($this->_type === 'oci') {
514            // workaround for https://bugs.php.net/bug.php?id=46728
515            $result = [];
516            while ($row = $statement->fetch(PDO::FETCH_ASSOC)) {
517                $result[] = array_map('PrivateBin\Data\Database::_sanitizeClob', $row);
518            }
519        } else {
520            $result = $statement->fetchAll(PDO::FETCH_ASSOC);
521        }
522        $statement->closeCursor();
523        return $result;
524    }
525
526    /**
527     * get table list query, depending on the database type
528     *
529     * @access private
530     * @param  string $type
531     * @throws Exception
532     * @return string
533     */
534    private function _getTableQuery($type)
535    {
536        switch ($type) {
537            case 'ibm':
538                $sql = 'SELECT "tabname" FROM "SYSCAT"."TABLES"';
539                break;
540            case 'informix':
541                $sql = 'SELECT "tabname" FROM "systables"';
542                break;
543            case 'mssql':
544                // U: tables created by the user
545                $sql = 'SELECT "name" FROM "sysobjects" '
546                     . 'WHERE "type" = \'U\' ORDER BY "name"';
547                break;
548            case 'mysql':
549                $sql = 'SHOW TABLES';
550                break;
551            case 'oci':
552                $sql = 'SELECT table_name FROM all_tables';
553                break;
554            case 'pgsql':
555                $sql = 'SELECT "tablename" FROM "pg_catalog"."pg_tables" '
556                     . 'WHERE "schemaname" NOT IN (\'pg_catalog\', \'information_schema\')';
557                break;
558            case 'sqlite':
559                $sql = 'SELECT "name" FROM "sqlite_master" WHERE "type"=\'table\' '
560                     . 'UNION ALL SELECT "name" FROM "sqlite_temp_master" '
561                     . 'WHERE "type"=\'table\' ORDER BY "name"';
562                break;
563            default:
564                throw new Exception(
565                    "PDO type $type is currently not supported.", 5
566                );
567        }
568        return $sql;
569    }
570
571    /**
572     * get a value by key from the config table
573     *
574     * @access private
575     * @param  string $key
576     * @return string
577     */
578    private function _getConfig($key)
579    {
580        try {
581            $row = $this->_select(
582                'SELECT "value" FROM "' . $this->_sanitizeIdentifier('config') .
583                '" WHERE "id" = ?', [$key], true
584            );
585        } catch (PDOException $e) {
586            error_log('Error while attempting to fetch configuration key "' . $key . '" in the database: ' . $e->getMessage());
587            return '';
588        }
589        return $row ? $row['value'] : '';
590    }
591
592    /**
593     * get the primary key clauses, depending on the database driver
594     *
595     * @access private
596     * @param  string $key
597     * @return array
598     */
599    private function _getPrimaryKeyClauses($key = 'dataid')
600    {
601        $main_key = $after_key = '';
602        switch ($this->_type) {
603            case 'mysql':
604            case 'oci':
605                $after_key = ", PRIMARY KEY (\"$key\")";
606                break;
607            default:
608                $main_key = ' PRIMARY KEY';
609                break;
610        }
611        return [$main_key, $after_key];
612    }
613
614    /**
615     * get the data type, depending on the database driver
616     *
617     * PostgreSQL and OCI uses a different API for BLOBs then SQL, hence we use TEXT and CLOB
618     *
619     * @access private
620     * @return string
621     */
622    private function _getDataType()
623    {
624        switch ($this->_type) {
625            case 'oci':
626                return 'CLOB';
627            case 'pgsql':
628                return 'TEXT';
629            default:
630                return 'BLOB';
631        }
632    }
633
634    /**
635     * get the attachment type, depending on the database driver
636     *
637     * PostgreSQL and OCI use different APIs for BLOBs then SQL, hence we use TEXT and CLOB
638     *
639     * @access private
640     * @return string
641     */
642    private function _getAttachmentType()
643    {
644        switch ($this->_type) {
645            case 'oci':
646                return 'CLOB';
647            case 'pgsql':
648                return 'TEXT';
649            default:
650                return 'MEDIUMBLOB';
651        }
652    }
653
654    /**
655     * get the meta type, depending on the database driver
656     *
657     * OCI doesn't accept TEXT so it has to be VARCHAR2(4000)
658     *
659     * @access private
660     * @return string
661     */
662    private function _getMetaType()
663    {
664        switch ($this->_type) {
665            case 'oci':
666                return 'VARCHAR2(4000)';
667            default:
668                return 'TEXT';
669        }
670    }
671
672    /**
673     * create the paste table
674     *
675     * @access private
676     */
677    private function _createPasteTable()
678    {
679        list($main_key, $after_key) = $this->_getPrimaryKeyClauses();
680        $attachmentType = $this->_getAttachmentType();
681        $metaType = $this->_getMetaType();
682        $this->_db->exec(
683            'CREATE TABLE "' . $this->_sanitizeIdentifier('paste') . '" ( ' .
684            "\"dataid\" CHAR(16) NOT NULL$main_key, " .
685            "\"data\" $attachmentType, " .
686            '"expiredate" INT, ' .
687            "\"meta\" $metaType$after_key )"
688        );
689    }
690
691    /**
692     * create the comment table
693     *
694     * @access private
695     */
696    private function _createCommentTable()
697    {
698        list($main_key, $after_key) = $this->_getPrimaryKeyClauses();
699        $dataType = $this->_getDataType();
700        $this->_db->exec(
701            'CREATE TABLE "' . $this->_sanitizeIdentifier('comment') . '" ( ' .
702            "\"dataid\" CHAR(16) NOT NULL$main_key, " .
703            '"pasteid" CHAR(16), ' .
704            '"parentid" CHAR(16), ' .
705            "\"data\" $dataType, " .
706            "\"vizhash\" $dataType, " .
707            "\"postdate\" INT$after_key )"
708        );
709        if ($this->_type === 'oci') {
710            $this->_db->exec(
711                'declare
712                    already_exists  exception;
713                    columns_indexed exception;
714                    pragma exception_init( already_exists, -955 );
715                    pragma exception_init(columns_indexed, -1408);
716                begin
717                    execute immediate \'create index "comment_parent" on "' . $this->_sanitizeIdentifier('comment') . '" ("pasteid")\';
718                exception
719                    when already_exists or columns_indexed then
720                    NULL;
721                end;'
722            );
723        } else {
724            // CREATE INDEX IF NOT EXISTS not supported as of Oracle MySQL <= 8.0
725            $this->_db->exec(
726                'CREATE INDEX "' .
727                $this->_sanitizeIdentifier('comment_parent') . '" ON "' .
728                $this->_sanitizeIdentifier('comment') . '" ("pasteid")'
729            );
730        }
731    }
732
733    /**
734     * create the config table
735     *
736     * @access private
737     */
738    private function _createConfigTable()
739    {
740        list($main_key, $after_key) = $this->_getPrimaryKeyClauses('id');
741        $charType = $this->_type === 'oci' ? 'VARCHAR2(16)' : 'CHAR(16)';
742        $textType = $this->_getMetaType();
743        $this->_db->exec(
744            'CREATE TABLE "' . $this->_sanitizeIdentifier('config') .
745            "\" ( \"id\" $charType NOT NULL$main_key, \"value\" $textType$after_key )"
746        );
747        $this->_exec(
748            'INSERT INTO "' . $this->_sanitizeIdentifier('config') .
749            '" VALUES(?,?)',
750            ['VERSION', Controller::VERSION]
751        );
752    }
753
754    /**
755     * sanitizes CLOB values used with OCI
756     *
757     * From: https://stackoverflow.com/questions/36200534/pdo-oci-into-a-clob-field
758     *
759     * @access public
760     * @static
761     * @param  int|string|resource $value
762     * @return int|string
763     */
764    public static function _sanitizeClob($value)
765    {
766        if (is_resource($value)) {
767            $value = stream_get_contents($value);
768        }
769        return $value;
770    }
771
772    /**
773     * sanitizes identifiers
774     *
775     * @access private
776     * @param  string $identifier
777     * @return string
778     */
779    private function _sanitizeIdentifier($identifier)
780    {
781        return preg_replace('/[^A-Za-z0-9_]+/', '', $this->_prefix . $identifier);
782    }
783
784    /**
785     * check if the current database type supports dropping columns
786     *
787     * @access private
788     * @return bool
789     */
790    private function _supportsDropColumn()
791    {
792        $supportsDropColumn = true;
793        if ($this->_type === 'sqlite') {
794            try {
795                $row = $this->_select('SELECT sqlite_version() AS "v"', [], true);
796                $supportsDropColumn = (bool) version_compare($row['v'], '3.35.0', '>=');
797            } catch (PDOException $e) {
798                $supportsDropColumn = false;
799            }
800        }
801        return $supportsDropColumn;
802    }
803
804    /**
805     * upgrade the database schema from an old version
806     *
807     * @access private
808     * @param  string $oldversion
809     */
810    private function _upgradeDatabase($oldversion)
811    {
812        $dataType = $this->_getDataType();
813        $attachmentType = $this->_getAttachmentType();
814        if (version_compare($oldversion, '0.21', '<=')) {
815            // create the meta column if necessary (pre 0.21 change)
816            try {
817                $this->_db->exec(
818                    'SELECT "meta" FROM "' . $this->_sanitizeIdentifier('paste') . '" ' .
819                    ($this->_type === 'oci' ? 'FETCH NEXT 1 ROWS ONLY' : 'LIMIT 1')
820                );
821            } catch (PDOException $e) {
822                $this->_db->exec('ALTER TABLE "' . $this->_sanitizeIdentifier('paste') . '" ADD COLUMN "meta" TEXT');
823            }
824            // SQLite only allows one ALTER statement at a time...
825            $this->_db->exec(
826                'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
827                "\" ADD COLUMN \"attachment\" $attachmentType"
828            );
829            $this->_db->exec(
830                'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') . "\" ADD COLUMN \"attachmentname\" $dataType"
831            );
832            // SQLite doesn't support MODIFY, but it allows TEXT of similar
833            // size as BLOB, so there is no need to change it there
834            if ($this->_type !== 'sqlite') {
835                $this->_db->exec(
836                    'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
837                    "\" ADD PRIMARY KEY (\"dataid\"), MODIFY COLUMN \"data\" $dataType"
838                );
839                $this->_db->exec(
840                    'ALTER TABLE "' . $this->_sanitizeIdentifier('comment') .
841                    "\" ADD PRIMARY KEY (\"dataid\"), MODIFY COLUMN \"data\" $dataType, " .
842                    "MODIFY COLUMN \"nickname\" $dataType, MODIFY COLUMN \"vizhash\" $dataType"
843                );
844            } else {
845                $this->_db->exec(
846                    'CREATE UNIQUE INDEX IF NOT EXISTS "' .
847                    $this->_sanitizeIdentifier('paste_dataid') . '" ON "' .
848                    $this->_sanitizeIdentifier('paste') . '" ("dataid")'
849                );
850                $this->_db->exec(
851                    'CREATE UNIQUE INDEX IF NOT EXISTS "' .
852                    $this->_sanitizeIdentifier('comment_dataid') . '" ON "' .
853                    $this->_sanitizeIdentifier('comment') . '" ("dataid")'
854                );
855            }
856            // CREATE INDEX IF NOT EXISTS not supported as of Oracle MySQL <= 8.0
857            $this->_db->exec(
858                'CREATE INDEX "' .
859                $this->_sanitizeIdentifier('comment_parent') . '" ON "' .
860                $this->_sanitizeIdentifier('comment') . '" ("pasteid")'
861            );
862        }
863        if (version_compare($oldversion, '1.3', '<=')) {
864            // SQLite doesn't support MODIFY, but it allows TEXT of similar
865            // size as BLOB and PostgreSQL uses TEXT, so there is no need
866            // to change it there
867            if ($this->_type !== 'sqlite' && $this->_type !== 'pgsql') {
868                $this->_db->exec(
869                    'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
870                    "\" MODIFY COLUMN \"data\" $attachmentType"
871                );
872            }
873        }
874        if (version_compare($oldversion, '1.7.1', '<=')) {
875            if ($this->_supportsDropColumn()) {
876                $this->_db->exec(
877                    'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
878                    '" DROP COLUMN "postdate"'
879                );
880            }
881        }
882        if (version_compare($oldversion, '1.7.8', '<=')) {
883            if ($this->_supportsDropColumn()) {
884                $this->_db->exec(
885                    'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
886                    '" DROP COLUMN "opendiscussion"'
887                );
888                $this->_db->exec(
889                    'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
890                    '" DROP COLUMN "burnafterreading"'
891                );
892                $this->_db->exec(
893                    'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
894                    '" DROP COLUMN "attachment"'
895                );
896                $this->_db->exec(
897                    'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
898                    '" DROP COLUMN "attachmentname"'
899                );
900                $this->_db->exec(
901                    'ALTER TABLE "' . $this->_sanitizeIdentifier('comment') .
902                    '" DROP COLUMN "nickname"'
903                );
904            }
905        }
906        $this->_exec(
907            'UPDATE "' . $this->_sanitizeIdentifier('config') .
908            '" SET "value" = ? WHERE "id" = ?',
909            [Controller::VERSION, 'VERSION']
910        );
911    }
912}