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