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