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