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