Issues (14)

lib/Data/Database.php (1 issue)

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
12
namespace PrivateBin\Data;
13
14
use Exception;
15
use PDO;
16
use PDOException;
17
use PrivateBin\Controller;
18
use PrivateBin\Json;
19
20
/**
21
 * Database
22
 *
23
 * Model for database access, implemented as a singleton.
24
 */
25
class 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 81
    public function __construct(array $options)
59
    {
60
        // set table prefix if given
61 81
        if (array_key_exists('tbl', $options)) {
62 55
            $this->_prefix = $options['tbl'];
63
        }
64
65
        // initialize the db connection with new options
66
        if (
67 81
            array_key_exists('dsn', $options) &&
68 81
            array_key_exists('usr', $options) &&
69 81
            array_key_exists('pwd', $options) &&
70 81
            array_key_exists('opt', $options)
71
        ) {
72
            // set default options
73 81
            $options['opt'][PDO::ATTR_ERRMODE]          = PDO::ERRMODE_EXCEPTION;
74 81
            $options['opt'][PDO::ATTR_EMULATE_PREPARES] = false;
75 81
            if (!array_key_exists(PDO::ATTR_PERSISTENT, $options['opt'])) {
76 43
                $options['opt'][PDO::ATTR_PERSISTENT] = true;
77
            }
78 81
            $db_tables_exist                            = true;
79
80
            // setup type and dabase connection
81 81
            $this->_type = strtolower(
82 81
                substr($options['dsn'], 0, strpos($options['dsn'], ':'))
83 81
            );
84
            // MySQL uses backticks to quote identifiers by default,
85
            // tell it to expect ANSI SQL double quotes
86 81
            if ($this->_type === 'mysql' && defined('PDO::MYSQL_ATTR_INIT_COMMAND')) {
87 1
                $options['opt'][PDO::MYSQL_ATTR_INIT_COMMAND] = "SET SESSION sql_mode='ANSI_QUOTES'";
88
            }
89 81
            $tableQuery = $this->_getTableQuery($this->_type);
90 81
            $this->_db  = new PDO(
91 81
                $options['dsn'],
92 81
                $options['usr'],
93 81
                $options['pwd'],
94 81
                $options['opt']
95 81
            );
96
97
            // check if the database contains the required tables
98 81
            $tables = $this->_db->query($tableQuery)->fetchAll(PDO::FETCH_COLUMN, 0);
99
100
            // create paste table if necessary
101 81
            if (!in_array($this->_sanitizeIdentifier('paste'), $tables)) {
102 45
                $this->_createPasteTable();
103 45
                $db_tables_exist = false;
104
            }
105
106
            // create comment table if necessary
107 81
            if (!in_array($this->_sanitizeIdentifier('comment'), $tables)) {
108 45
                $this->_createCommentTable();
109 45
                $db_tables_exist = false;
110
            }
111
112
            // create config table if necessary
113 81
            $db_version = Controller::VERSION;
114 81
            if (!in_array($this->_sanitizeIdentifier('config'), $tables)) {
115 46
                $this->_createConfigTable();
116
                // if we only needed to create the config table, the DB is older then 0.22
117 46
                if ($db_tables_exist) {
118 46
                    $db_version = '0.21';
119
                }
120
            } else {
121 73
                $db_version = $this->_getConfig('VERSION');
122
            }
123
124
            // update database structure if necessary
125 81
            if (version_compare($db_version, Controller::VERSION, '<')) {
126 81
                $this->_upgradeDatabase($db_version);
127
            }
128
        } else {
129 4
            throw new Exception(
130 4
                'Missing configuration for key dsn, usr, pwd or opt in the section model_options, please check your configuration file', 6
131 4
            );
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 41
    public function create($pasteid, array &$paste)
144
    {
145 41
        $expire_date      = 0;
146 41
        $opendiscussion   = $burnafterreading = false;
147 41
        $attachment       = $attachmentname   = null;
148 41
        $meta             = $paste['meta'];
149 41
        $isVersion1       = array_key_exists('data', $paste);
150 41
        if (array_key_exists('expire_date', $meta)) {
151 24
            $expire_date = (int) $meta['expire_date'];
152 24
            unset($meta['expire_date']);
153
        }
154 41
        if (array_key_exists('opendiscussion', $meta)) {
155 2
            $opendiscussion = $meta['opendiscussion'];
156 2
            unset($meta['opendiscussion']);
157
        }
158 41
        if (array_key_exists('burnafterreading', $meta)) {
159 1
            $burnafterreading = $meta['burnafterreading'];
160 1
            unset($meta['burnafterreading']);
161
        }
162 41
        if ($isVersion1) {
163 2
            if (array_key_exists('attachment', $meta)) {
164 1
                $attachment = $meta['attachment'];
165 1
                unset($meta['attachment']);
166
            }
167 2
            if (array_key_exists('attachmentname', $meta)) {
168 1
                $attachmentname = $meta['attachmentname'];
169 2
                unset($meta['attachmentname']);
170
            }
171
        } else {
172 39
            $opendiscussion   = $paste['adata'][2];
173 39
            $burnafterreading = $paste['adata'][3];
174
        }
175
        try {
176 41
            return $this->_exec(
177 41
                'INSERT INTO "' . $this->_sanitizeIdentifier('paste') .
178 41
                '" VALUES(?,?,?,?,?,?,?,?)',
179 41
                array(
180 41
                    $pasteid,
181 41
                    $isVersion1 ? $paste['data'] : Json::encode($paste),
182 41
                    $expire_date,
183 41
                    (int) $opendiscussion,
184 41
                    (int) $burnafterreading,
185 41
                    Json::encode($meta),
186 41
                    $attachment,
187 41
                    $attachmentname,
188 41
                )
189 41
            );
190 4
        } catch (Exception $e) {
191 4
            error_log('Error while attempting to insert a paste into the database: ' . $e->getMessage() . PHP_EOL);
192 4
            return false;
193
        }
194
    }
195
196
    /**
197
     * Read a paste.
198
     *
199
     * @access public
200
     * @param  string $pasteid
201
     * @return array|false
202
     */
203 30
    public function read($pasteid)
204
    {
205
        try {
206 30
            $row = $this->_select(
207 30
                'SELECT * FROM "' . $this->_sanitizeIdentifier('paste') .
208 30
                '" WHERE "dataid" = ?', array($pasteid), true
209 30
            );
210
        } catch (Exception $e) {
211
            $row = false;
212
        }
213 30
        if ($row === false) {
214 2
            return false;
215
        }
216
        // create array
217 29
        $data       = Json::decode($row['data']);
218 29
        $isVersion2 = array_key_exists('v', $data) && $data['v'] >= 2;
219 29
        $paste      = $isVersion2 ? $data : array('data' => $row['data']);
220
221
        try {
222 29
            $row['meta'] = Json::decode($row['meta']);
223 1
        } catch (Exception $e) {
224 1
            $row['meta'] = array();
225
        }
226 29
        $row                        = self::upgradePreV1Format($row);
227 29
        $paste['meta']              = $row['meta'];
228 29
        $expire_date                = (int) $row['expiredate'];
229 29
        if ($expire_date > 0) {
230 16
            $paste['meta']['expire_date'] = $expire_date;
231
        }
232 29
        if ($isVersion2) {
233 24
            return $paste;
234
        }
235
236
        // support v1 attachments
237 5
        if (array_key_exists('attachment', $row) && !empty($row['attachment'])) {
238 2
            $paste['attachment'] = $row['attachment'];
239 2
            if (array_key_exists('attachmentname', $row) && !empty($row['attachmentname'])) {
240 2
                $paste['attachmentname'] = $row['attachmentname'];
241
            }
242
        }
243 5
        if ($row['opendiscussion']) {
244 2
            $paste['meta']['opendiscussion'] = true;
245
        }
246 5
        if ($row['burnafterreading']) {
247 1
            $paste['meta']['burnafterreading'] = true;
248
        }
249
250 5
        return $paste;
251
    }
252
253
    /**
254
     * Delete a paste and its discussion.
255
     *
256
     * @access public
257
     * @param  string $pasteid
258
     */
259 28
    public function delete($pasteid)
260
    {
261 28
        $this->_exec(
262 28
            'DELETE FROM "' . $this->_sanitizeIdentifier('paste') .
263 28
            '" WHERE "dataid" = ?', array($pasteid)
264 28
        );
265 28
        $this->_exec(
266 28
            'DELETE FROM "' . $this->_sanitizeIdentifier('comment') .
267 28
            '" WHERE "pasteid" = ?', array($pasteid)
268 28
        );
269
    }
270
271
    /**
272
     * Test if a paste exists.
273
     *
274
     * @access public
275
     * @param  string $pasteid
276
     * @return bool
277
     */
278 62
    public function exists($pasteid)
279
    {
280
        try {
281 62
            $row = $this->_select(
282 62
                'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('paste') .
283 62
                '" WHERE "dataid" = ?', array($pasteid), true
284 62
            );
285 1
        } catch (Exception $e) {
286 1
            return false;
287
        }
288 62
        return (bool) $row;
289
    }
290
291
    /**
292
     * Create a comment in a paste.
293
     *
294
     * @access public
295
     * @param  string $pasteid
296
     * @param  string $parentid
297
     * @param  string $commentid
298
     * @param  array  $comment
299
     * @return bool
300
     */
301 10
    public function createComment($pasteid, $parentid, $commentid, array &$comment)
302
    {
303 10
        if (array_key_exists('data', $comment)) {
304 1
            $version = 1;
305 1
            $data    = $comment['data'];
306
        } else {
307
            try {
308 10
                $version = 2;
309 10
                $data    = Json::encode($comment);
310 1
            } catch (Exception $e) {
311 1
                return false;
312
            }
313
        }
314 9
        list($createdKey, $iconKey) = $this->_getVersionedKeys($version);
315 9
        $meta                       = $comment['meta'];
316 9
        unset($comment['meta']);
317 9
        foreach (array('nickname', $iconKey) as $key) {
318 9
            if (!array_key_exists($key, $meta)) {
319 9
                $meta[$key] = null;
320
            }
321
        }
322
        try {
323 9
            return $this->_exec(
324 9
                'INSERT INTO "' . $this->_sanitizeIdentifier('comment') .
325 9
                '" VALUES(?,?,?,?,?,?,?)',
326 9
                array(
327 9
                    $commentid,
328 9
                    $pasteid,
329 9
                    $parentid,
330 9
                    $data,
331 9
                    $meta['nickname'],
332 9
                    $meta[$iconKey],
333 9
                    $meta[$createdKey],
334 9
                )
335 9
            );
336 1
        } catch (Exception $e) {
337 1
            error_log('Error while attempting to insert a comment into the database: ' . $e->getMessage() . PHP_EOL);
338 1
            return false;
339
        }
340
    }
341
342
    /**
343
     * Read all comments of paste.
344
     *
345
     * @access public
346
     * @param  string $pasteid
347
     * @return array
348
     */
349 18
    public function readComments($pasteid)
350
    {
351 18
        $rows = $this->_select(
352 18
            'SELECT * FROM "' . $this->_sanitizeIdentifier('comment') .
353 18
            '" WHERE "pasteid" = ?', array($pasteid)
354 18
        );
355
356
        // create comment list
357 18
        $comments = array();
358 18
        if (is_array($rows) && count($rows)) {
359 6
            foreach ($rows as $row) {
360 6
                $i    = $this->getOpenSlot($comments, (int) $row['postdate']);
361 6
                $data = Json::decode($row['data']);
362 6
                if (array_key_exists('v', $data) && $data['v'] >= 2) {
363 6
                    $version      = 2;
364 6
                    $comments[$i] = $data;
365
                } else {
366 1
                    $version      = 1;
367 1
                    $comments[$i] = array('data' => $row['data']);
368
                }
369 6
                list($createdKey, $iconKey) = $this->_getVersionedKeys($version);
370 6
                $comments[$i]['id']         = $row['dataid'];
371 6
                $comments[$i]['parentid']   = $row['parentid'];
372 6
                $comments[$i]['meta']       = array($createdKey => (int) $row['postdate']);
373 6
                foreach (array('nickname' => 'nickname', 'vizhash' => $iconKey) as $rowKey => $commentKey) {
374 6
                    if (array_key_exists($rowKey, $row) && !empty($row[$rowKey])) {
375 5
                        $comments[$i]['meta'][$commentKey] = $row[$rowKey];
376
                    }
377
                }
378
            }
379 6
            ksort($comments);
380
        }
381 18
        return $comments;
382
    }
383
384
    /**
385
     * Test if a comment exists.
386
     *
387
     * @access public
388
     * @param  string $pasteid
389
     * @param  string $parentid
390
     * @param  string $commentid
391
     * @return bool
392
     */
393 14
    public function existsComment($pasteid, $parentid, $commentid)
394
    {
395
        try {
396 14
            return (bool) $this->_select(
397 14
                'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('comment') .
398 14
                '" WHERE "pasteid" = ? AND "parentid" = ? AND "dataid" = ?',
399 14
                array($pasteid, $parentid, $commentid), true
400 14
            );
401 1
        } catch (Exception $e) {
402 1
            return false;
403
        }
404
    }
405
406
    /**
407
     * Save a value.
408
     *
409
     * @access public
410
     * @param  string $value
411
     * @param  string $namespace
412
     * @param  string $key
413
     * @return bool
414
     */
415 17
    public function setValue($value, $namespace, $key = '')
416
    {
417 17
        if ($namespace === 'traffic_limiter') {
418 4
            $this->_last_cache[$key] = $value;
419
            try {
420 4
                $value = Json::encode($this->_last_cache);
421
            } catch (Exception $e) {
422
                return false;
423
            }
424
        }
425 17
        return $this->_exec(
426 17
            'UPDATE "' . $this->_sanitizeIdentifier('config') .
427 17
            '" SET "value" = ? WHERE "id" = ?',
428 17
            array($value, strtoupper($namespace))
429 17
        );
430
    }
431
432
    /**
433
     * Load a value.
434
     *
435
     * @access public
436
     * @param  string $namespace
437
     * @param  string $key
438
     * @return string
439
     */
440 22
    public function getValue($namespace, $key = '')
441
    {
442 22
        $configKey = strtoupper($namespace);
443 22
        $value     = $this->_getConfig($configKey);
444 22
        if ($value === '') {
445
            // initialize the row, so that setValue can rely on UPDATE queries
446 17
            $this->_exec(
447 17
                'INSERT INTO "' . $this->_sanitizeIdentifier('config') .
448 17
                '" VALUES(?,?)',
449 17
                array($configKey, '')
450 17
            );
451
452
            // migrate filesystem based salt into database
453 17
            $file = 'data' . DIRECTORY_SEPARATOR . 'salt.php';
454 17
            if ($namespace === 'salt' && is_readable($file)) {
455 1
                $fs    = new Filesystem(array('dir' => 'data'));
456 1
                $value = $fs->getValue('salt');
457 1
                $this->setValue($value, 'salt');
458 1
                @unlink($file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

458
                /** @scrutinizer ignore-unhandled */ @unlink($file);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
459 1
                return $value;
460
            }
461
        }
462 21
        if ($value && $namespace === 'traffic_limiter') {
463
            try {
464 1
                $this->_last_cache = Json::decode($value);
465
            } catch (Exception $e) {
466
                $this->_last_cache = array();
467
            }
468 1
            if (array_key_exists($key, $this->_last_cache)) {
469 1
                return $this->_last_cache[$key];
470
            }
471
        }
472 21
        return (string) $value;
473
    }
474
475
    /**
476
     * Returns up to batch size number of paste ids that have expired
477
     *
478
     * @access private
479
     * @param  int $batchsize
480
     * @return array
481
     */
482 12
    protected function _getExpiredPastes($batchsize)
483
    {
484 12
        $statement = $this->_db->prepare(
485 12
            'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('paste') .
486 12
            '" WHERE "expiredate" < ? AND "expiredate" != ? ' .
487 12
            ($this->_type === 'oci' ? 'FETCH NEXT ? ROWS ONLY' : 'LIMIT ?')
488 12
        );
489 12
        $statement->execute(array(time(), 0, $batchsize));
490 12
        return $statement->fetchAll(PDO::FETCH_COLUMN, 0);
491
    }
492
493
    /**
494
     * @inheritDoc
495
     */
496
    public function getAllPastes()
497
    {
498
        return $this->_db->query(
499
            'SELECT "dataid" FROM "' . $this->_sanitizeIdentifier('paste') . '"'
500
        )->fetchAll(PDO::FETCH_COLUMN, 0);
501
    }
502
503
    /**
504
     * execute a statement
505
     *
506
     * @access private
507
     * @param  string $sql
508
     * @param  array $params
509
     * @throws PDOException
510
     * @return bool
511
     */
512 63
    private function _exec($sql, array $params)
513
    {
514 63
        $statement = $this->_db->prepare($sql);
515 63
        $position  = 1;
516 63
        foreach ($params as &$parameter) {
517 63
            if (is_int($parameter)) {
518 39
                $statement->bindParam($position, $parameter, PDO::PARAM_INT);
519 63
            } elseif (is_string($parameter) && strlen($parameter) >= 4000) {
520
                $statement->bindParam($position, $parameter, PDO::PARAM_STR, strlen($parameter));
521
            } else {
522 63
                $statement->bindParam($position, $parameter);
523
            }
524 63
            ++$position;
525
        }
526 63
        $result = $statement->execute();
527 63
        $statement->closeCursor();
528 63
        return $result;
529
    }
530
531
    /**
532
     * run a select statement
533
     *
534
     * @access private
535
     * @param  string $sql
536
     * @param  array $params
537
     * @param  bool $firstOnly if only the first row should be returned
538
     * @throws PDOException
539
     * @return array|false
540
     */
541 81
    private function _select($sql, array $params, $firstOnly = false)
542
    {
543 81
        $statement = $this->_db->prepare($sql);
544 81
        $statement->execute($params);
545 81
        if ($firstOnly) {
546 81
            $result = $statement->fetch(PDO::FETCH_ASSOC);
547 18
        } elseif ($this->_type === 'oci') {
548
            // workaround for https://bugs.php.net/bug.php?id=46728
549
            $result = array();
550
            while ($row = $statement->fetch(PDO::FETCH_ASSOC)) {
551
                $result[] = array_map('PrivateBin\Data\Database::_sanitizeClob', $row);
552
            }
553
        } else {
554 18
            $result = $statement->fetchAll(PDO::FETCH_ASSOC);
555
        }
556 81
        $statement->closeCursor();
557 81
        if ($this->_type === 'oci' && is_array($result)) {
558
            // returned CLOB values are streams, convert these into strings
559
            $result = $firstOnly ?
560
                array_map('PrivateBin\Data\Database::_sanitizeClob', $result) :
561
                $result;
562
        }
563 81
        return $result;
564
    }
565
566
    /**
567
     * get version dependent key names
568
     *
569
     * @access private
570
     * @param  int $version
571
     * @return array
572
     */
573 9
    private function _getVersionedKeys($version)
574
    {
575 9
        if ($version === 1) {
576 1
            return array('postdate', 'vizhash');
577
        }
578 9
        return array('created', 'icon');
579
    }
580
581
    /**
582
     * get table list query, depending on the database type
583
     *
584
     * @access private
585
     * @param  string $type
586
     * @throws Exception
587
     * @return string
588
     */
589 81
    private function _getTableQuery($type)
590
    {
591
        switch ($type) {
592 81
            case 'ibm':
593 1
                $sql = 'SELECT "tabname" FROM "SYSCAT"."TABLES"';
594 1
                break;
595 81
            case 'informix':
596 1
                $sql = 'SELECT "tabname" FROM "systables"';
597 1
                break;
598 81
            case 'mssql':
599
                // U: tables created by the user
600 1
                $sql = 'SELECT "name" FROM "sysobjects" '
601 1
                     . 'WHERE "type" = \'U\' ORDER BY "name"';
602 1
                break;
603 81
            case 'mysql':
604 1
                $sql = 'SHOW TABLES';
605 1
                break;
606 81
            case 'oci':
607 1
                $sql = 'SELECT table_name FROM all_tables';
608 1
                break;
609 81
            case 'pgsql':
610 1
                $sql = 'SELECT "tablename" FROM "pg_catalog"."pg_tables" '
611 1
                     . 'WHERE "schemaname" NOT IN (\'pg_catalog\', \'information_schema\')';
612 1
                break;
613 81
            case 'sqlite':
614 81
                $sql = 'SELECT "name" FROM "sqlite_master" WHERE "type"=\'table\' '
615 81
                     . 'UNION ALL SELECT "name" FROM "sqlite_temp_master" '
616 81
                     . 'WHERE "type"=\'table\' ORDER BY "name"';
617 81
                break;
618
            default:
619 1
                throw new Exception(
620 1
                    "PDO type $type is currently not supported.", 5
621 1
                );
622
        }
623 81
        return $sql;
624
    }
625
626
    /**
627
     * get a value by key from the config table
628
     *
629
     * @access private
630
     * @param  string $key
631
     * @return string
632
     */
633 74
    private function _getConfig($key)
634
    {
635
        try {
636 74
            $row = $this->_select(
637 74
                'SELECT "value" FROM "' . $this->_sanitizeIdentifier('config') .
638 74
                '" WHERE "id" = ?', array($key), true
639 74
            );
640
        } catch (PDOException $e) {
641
            return '';
642
        }
643 74
        return $row ? $row['value'] : '';
644
    }
645
646
    /**
647
     * get the primary key clauses, depending on the database driver
648
     *
649
     * @access private
650
     * @param  string $key
651
     * @return array
652
     */
653 46
    private function _getPrimaryKeyClauses($key = 'dataid')
654
    {
655 46
        $main_key = $after_key = '';
656 46
        switch ($this->_type) {
657 46
            case 'mysql':
658 46
            case 'oci':
659
                $after_key = ", PRIMARY KEY (\"$key\")";
660
                break;
661
            default:
662 46
                $main_key = ' PRIMARY KEY';
663 46
                break;
664
        }
665 46
        return array($main_key, $after_key);
666
    }
667
668
    /**
669
     * get the data type, depending on the database driver
670
     *
671
     * PostgreSQL and OCI uses a different API for BLOBs then SQL, hence we use TEXT and CLOB
672
     *
673
     * @access private
674
     * @return string
675
     */
676 46
    private function _getDataType()
677
    {
678 46
        switch ($this->_type) {
679 46
            case 'oci':
680
                return 'CLOB';
681 46
            case 'pgsql':
682
                return 'TEXT';
683
            default:
684 46
                return 'BLOB';
685
        }
686
    }
687
688
    /**
689
     * get the attachment type, depending on the database driver
690
     *
691
     * PostgreSQL and OCI use different APIs for BLOBs then SQL, hence we use TEXT and CLOB
692
     *
693
     * @access private
694
     * @return string
695
     */
696 46
    private function _getAttachmentType()
697
    {
698 46
        switch ($this->_type) {
699 46
            case 'oci':
700
                return 'CLOB';
701 46
            case 'pgsql':
702
                return 'TEXT';
703
            default:
704 46
                return 'MEDIUMBLOB';
705
        }
706
    }
707
708
    /**
709
     * get the meta type, depending on the database driver
710
     *
711
     * OCI doesn't accept TEXT so it has to be VARCHAR2(4000)
712
     *
713
     * @access private
714
     * @return string
715
     */
716 46
    private function _getMetaType()
717
    {
718 46
        switch ($this->_type) {
719 46
            case 'oci':
720
                return 'VARCHAR2(4000)';
721
            default:
722 46
                return 'TEXT';
723
        }
724
    }
725
726
    /**
727
     * create the paste table
728
     *
729
     * @access private
730
     */
731 45
    private function _createPasteTable()
732
    {
733 45
        list($main_key, $after_key) = $this->_getPrimaryKeyClauses();
734 45
        $dataType                   = $this->_getDataType();
735 45
        $attachmentType             = $this->_getAttachmentType();
736 45
        $metaType                   = $this->_getMetaType();
737 45
        $this->_db->exec(
738 45
            'CREATE TABLE "' . $this->_sanitizeIdentifier('paste') . '" ( ' .
739 45
            "\"dataid\" CHAR(16) NOT NULL$main_key, " .
740 45
            "\"data\" $attachmentType, " .
741 45
            '"expiredate" INT, ' .
742 45
            '"opendiscussion" INT, ' .
743 45
            '"burnafterreading" INT, ' .
744 45
            "\"meta\" $metaType, " .
745 45
            "\"attachment\" $attachmentType, " .
746 45
            "\"attachmentname\" $dataType$after_key )"
747 45
        );
748
    }
749
750
    /**
751
     * create the paste table
752
     *
753
     * @access private
754
     */
755 45
    private function _createCommentTable()
756
    {
757 45
        list($main_key, $after_key) = $this->_getPrimaryKeyClauses();
758 45
        $dataType                   = $this->_getDataType();
759 45
        $this->_db->exec(
760 45
            'CREATE TABLE "' . $this->_sanitizeIdentifier('comment') . '" ( ' .
761 45
            "\"dataid\" CHAR(16) NOT NULL$main_key, " .
762 45
            '"pasteid" CHAR(16), ' .
763 45
            '"parentid" CHAR(16), ' .
764 45
            "\"data\" $dataType, " .
765 45
            "\"nickname\" $dataType, " .
766 45
            "\"vizhash\" $dataType, " .
767 45
            "\"postdate\" INT$after_key )"
768 45
        );
769 45
        if ($this->_type === 'oci') {
770
            $this->_db->exec(
771
                'declare
772
                    already_exists  exception;
773
                    columns_indexed exception;
774
                    pragma exception_init( already_exists, -955 );
775
                    pragma exception_init(columns_indexed, -1408);
776
                begin
777
                    execute immediate \'create index "comment_parent" on "' . $this->_sanitizeIdentifier('comment') . '" ("pasteid")\';
778
                exception
779
                    when already_exists or columns_indexed then
780
                    NULL;
781
                end;'
782
            );
783
        } else {
784
            // CREATE INDEX IF NOT EXISTS not supported as of Oracle MySQL <= 8.0
785 45
            $this->_db->exec(
786 45
                'CREATE INDEX "' .
787 45
                $this->_sanitizeIdentifier('comment_parent') . '" ON "' .
788 45
                $this->_sanitizeIdentifier('comment') . '" ("pasteid")'
789 45
            );
790
        }
791
    }
792
793
    /**
794
     * create the paste table
795
     *
796
     * @access private
797
     */
798 46
    private function _createConfigTable()
799
    {
800 46
        list($main_key, $after_key) = $this->_getPrimaryKeyClauses('id');
801 46
        $charType                   = $this->_type === 'oci' ? 'VARCHAR2(16)' : 'CHAR(16)';
802 46
        $textType                   = $this->_getMetaType();
803 46
        $this->_db->exec(
804 46
            'CREATE TABLE "' . $this->_sanitizeIdentifier('config') .
805 46
            "\" ( \"id\" $charType NOT NULL$main_key, \"value\" $textType$after_key )"
806 46
        );
807 46
        $this->_exec(
808 46
            'INSERT INTO "' . $this->_sanitizeIdentifier('config') .
809 46
            '" VALUES(?,?)',
810 46
            array('VERSION', Controller::VERSION)
811 46
        );
812
    }
813
814
    /**
815
     * sanitizes CLOB values used with OCI
816
     *
817
     * From: https://stackoverflow.com/questions/36200534/pdo-oci-into-a-clob-field
818
     *
819
     * @access public
820
     * @static
821
     * @param  int|string|resource $value
822
     * @return int|string
823
     */
824 1
    public static function _sanitizeClob($value)
825
    {
826 1
        if (is_resource($value)) {
827 1
            $value = stream_get_contents($value);
828
        }
829 1
        return $value;
830
    }
831
832
    /**
833
     * sanitizes identifiers
834
     *
835
     * @access private
836
     * @param  string $identifier
837
     * @return string
838
     */
839 81
    private function _sanitizeIdentifier($identifier)
840
    {
841 81
        return preg_replace('/[^A-Za-z0-9_]+/', '', $this->_prefix . $identifier);
842
    }
843
844
    /**
845
     * upgrade the database schema from an old version
846
     *
847
     * @access private
848
     * @param  string $oldversion
849
     */
850 1
    private function _upgradeDatabase($oldversion)
851
    {
852 1
        $dataType       = $this->_getDataType();
853 1
        $attachmentType = $this->_getAttachmentType();
854 1
        if (version_compare($oldversion, '0.21', '<=')) {
855
            // create the meta column if necessary (pre 0.21 change)
856
            try {
857 1
                $this->_db->exec(
858 1
                    'SELECT "meta" FROM "' . $this->_sanitizeIdentifier('paste') . '" ' .
859 1
                    ($this->_type === 'oci' ? 'FETCH NEXT 1 ROWS ONLY' : 'LIMIT 1')
860 1
                );
861
            } catch (PDOException $e) {
862
                $this->_db->exec('ALTER TABLE "' . $this->_sanitizeIdentifier('paste') . '" ADD COLUMN "meta" TEXT');
863
            }
864
            // SQLite only allows one ALTER statement at a time...
865 1
            $this->_db->exec(
866 1
                'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
867 1
                "\" ADD COLUMN \"attachment\" $attachmentType"
868 1
            );
869 1
            $this->_db->exec(
870 1
                'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') . "\" ADD COLUMN \"attachmentname\" $dataType"
871 1
            );
872
            // SQLite doesn't support MODIFY, but it allows TEXT of similar
873
            // size as BLOB, so there is no need to change it there
874 1
            if ($this->_type !== 'sqlite') {
875
                $this->_db->exec(
876
                    'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
877
                    "\" ADD PRIMARY KEY (\"dataid\"), MODIFY COLUMN \"data\" $dataType"
878
                );
879
                $this->_db->exec(
880
                    'ALTER TABLE "' . $this->_sanitizeIdentifier('comment') .
881
                    "\" ADD PRIMARY KEY (\"dataid\"), MODIFY COLUMN \"data\" $dataType, " .
882
                    "MODIFY COLUMN \"nickname\" $dataType, MODIFY COLUMN \"vizhash\" $dataType"
883
                );
884
            } else {
885 1
                $this->_db->exec(
886 1
                    'CREATE UNIQUE INDEX IF NOT EXISTS "' .
887 1
                    $this->_sanitizeIdentifier('paste_dataid') . '" ON "' .
888 1
                    $this->_sanitizeIdentifier('paste') . '" ("dataid")'
889 1
                );
890 1
                $this->_db->exec(
891 1
                    'CREATE UNIQUE INDEX IF NOT EXISTS "' .
892 1
                    $this->_sanitizeIdentifier('comment_dataid') . '" ON "' .
893 1
                    $this->_sanitizeIdentifier('comment') . '" ("dataid")'
894 1
                );
895
            }
896
            // CREATE INDEX IF NOT EXISTS not supported as of Oracle MySQL <= 8.0
897 1
            $this->_db->exec(
898 1
                'CREATE INDEX "' .
899 1
                $this->_sanitizeIdentifier('comment_parent') . '" ON "' .
900 1
                $this->_sanitizeIdentifier('comment') . '" ("pasteid")'
901 1
            );
902
        }
903 1
        if (version_compare($oldversion, '1.3', '<=')) {
904
            // SQLite doesn't support MODIFY, but it allows TEXT of similar
905
            // size as BLOB and PostgreSQL uses TEXT, so there is no need
906
            // to change it there
907 1
            if ($this->_type !== 'sqlite' && $this->_type !== 'pgsql') {
908
                $this->_db->exec(
909
                    'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
910
                    "\" MODIFY COLUMN \"data\" $attachmentType"
911
                );
912
            }
913
        }
914 1
        if (version_compare($oldversion, '1.7.1', '<=')) {
915 1
            $supportsDropColumn = true;
916 1
            if ($this->_type === 'sqlite') {
917
                try {
918 1
                    $row                = $this->_select('SELECT sqlite_version() AS "v"', array(), true);
919 1
                    $supportsDropColumn = version_compare($row['v'], '3.35.0', '>=');
920 1
                    if (!$supportsDropColumn) {
921 1
                        error_log('Error: the available SQLite version (' . $row['v'] . ') does not support dropping columns. SQLite version 3.35.0 or later is necessary. The migration will not be fully complete, and you will need to delete the "postdate" column of the "paste" table yourself!');
922
                    }
923
                } catch (PDOException $e) {
924
                    $supportsDropColumn = false;
925
                    error_log('Error while checking the SQLite version. The migration will not be fully complete, and you will need to delete the "postdate" column of the "paste" table yourself!');
926
                }
927
            }
928 1
            if ($supportsDropColumn) {
929
                $this->_db->exec(
930
                    'ALTER TABLE "' . $this->_sanitizeIdentifier('paste') .
931
                    '" DROP COLUMN "postdate"'
932
                );
933
            }
934
        }
935 1
        $this->_exec(
936 1
            'UPDATE "' . $this->_sanitizeIdentifier('config') .
937 1
            '" SET "value" = ? WHERE "id" = ?',
938 1
            array(Controller::VERSION, 'VERSION')
939 1
        );
940
    }
941
}
942