From 421e6cba97d079e0206042dbf8daef1fcbfc4b63 Mon Sep 17 00:00:00 2001 From: Simon Rupf Date: Sat, 19 May 2012 23:59:41 +0200 Subject: [PATCH] implemented zerobin_db model, added more options for paste expiration, made comments and max data size configurable --- DOCUMENTATION.md | 96 ++++++++++++++ cfg/conf.ini | 45 ++++--- lib/zerobin.php | 50 +++++-- lib/zerobin/abstract.php | 2 +- lib/zerobin/data.php | 4 +- lib/zerobin/db.php | 280 ++++++++++++++++++++++++++++++++++++--- tpl/page.html | 4 +- 7 files changed, 432 insertions(+), 49 deletions(-) create mode 100644 DOCUMENTATION.md diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 00000000..fd9899aa --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,96 @@ +Documentation +============= + +For Administrators +------------------ + +In the index.php in the main folder you can define a different PATH. This is +useful if you want to secure your installation and want to move the +configuration, data files, templates and PHP libraries (directories cfg, lib +and tpl) outside of your document root. This new location must still be +accessible to your webserver / PHP process. + +> ### PATH Example ### +> Your zerobin installation lives in a subfolder called "paste" inside of your +> document root. The URL looks like this: +> http://example.com/paste/ +> The ZeroBin folder on your webserver is really: +> /home/example.com/htdocs/paste +> +> When setting the path like this: +> define('PATH', '../../secret/zerobin/'); +> ZeroBin will look for your includes here: +> /home/example.com/secret/zerobin + +In the file "cfg/conf.ini" you can configure ZeroBin. The config file is +divided into multiple sections, which are enclosed in square brackets. In the +"[main]" section you can enable or disable the discussion feature, set the +limit of stored pastes and comments in bytes. The "[traffic]" section lets you +set a time limit in seconds. Users may not post more often the this limit to +your ZeroBin. + +Finally the "[model]" and "[model_options]" sections let you configure your +favourite way of storing the pastes and discussions on your server. +"zerobin_data" is the default model, which stores everything in files in the +data folder. This is the recommended setup for low traffic sites. Under high +load, in distributed setups or if you are not allowed to store files locally, +you might want to switch to the "zerobin_db" model. This lets you store your +data in a database. Basically all databases, that are supported by PDO (PHP +data objects) may be used. Automatic table creation is provided for pdo_ibm, +pdo_informix, pdo_mssql, pdo_mysql, pdo_oci, pdo_pgsql and pdo_sqlite. You may +want to provide a table prefix, if you have to share the zerobin database with +another application. The table prefix option is called "tbl". + +> ### Note ### +> The "zerobin_db" model has only been tested with sqlite and MySQL, although +> it would not be recommended to use sqlite in a production environment. If you +> gain any experience running ZeroBin on other RDBMS, let us know. + +For reference or if you want to create the table schema for yourself: + + CREATE TABLE prefix_paste ( + dataid CHAR(16), + data TEXT, + postdate INT, + expiredate INT, + opendiscussion INT, + burnafterreading INT + ); + + CREATE TABLE prefix_comment ( + dataid CHAR(16), + pasteid CHAR(16), + parentid CHAR(16), + data TEXT, + nickname VARCHAR(255), + vizhash TEXT, + postdate INT + ); + +For Developers +-------------- +If you want to create your own data models, you might want to know how the arrays, that you have to store, look like: + + public function create($pasteid, $paste) + { + $pasteid = substr(hash('md5', $paste['data']), 0, 16); + + $paste['data'] // text + $paste['meta']['postdate'] // int UNIX timestamp + $paste['meta']['expire_date'] // int UNIX timestamp + $paste['meta']['opendiscussion'] // true (if false it is unset) + $paste['meta']['burnafterreading'] // true (if false it is unset; if true, then opendiscussion is unset) + } + + public function createComment($pasteid, $parentid, $commentid, $comment) + { + $pasteid // the id of the paste this comment belongs to + $parentid // the id of the parent of this comment, may be the paste id itself + $commentid = substr(hash('md5', $paste['data']), 0, 16); + + $paste['data'] // text + $paste['meta']['nickname'] // text or null (if anonymous) + $paste['meta']['vizhash'] // text or null (if anonymous) + $paste['meta']['postdate'] // int UNIX timestamp + } + diff --git a/cfg/conf.ini b/cfg/conf.ini index cbfdaafe..5608c852 100644 --- a/cfg/conf.ini +++ b/cfg/conf.ini @@ -7,28 +7,41 @@ ; @license http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License ; @version 0.15 -; time limit between calls from the same IP address in seconds -traffic_limit = 10 -traffic_dir = PATH "data" +[main] +; enable or disable discussions +opendiscussion = true ; size limit per paste or comment in bytes -size_limit = 2000000 +sizelimit = 2097152 +[traffic] +; time limit between calls from the same IP address in seconds +limit = 10 +dir = PATH "data" + +[model] ; name of data model class to load and directory for storage ; the default model "zerobin_data" stores everything in the filesystem -model = zerobin_data -model_options["dir"] = PATH "data" +class = zerobin_data +[model_options] +dir = PATH "data" +;[model] ; example of DB configuration for MySQL -;model = zerobin_db -;model_options["dsn"] = "mysql:host=localhost;dbname=zerobin" -;model_options["usr"] = "zerobin" -;model_options["pwd"] = "Z3r0P4ss" -;model_options["opt"][PDO::ATTR_PERSISTENT] = true +;class = zerobin_db +;[model_options] +;dsn = "mysql:host=localhost;dbname=zerobin;charset=UTF8" +;tbl = "zerobin_" ; table prefix +;usr = "zerobin" +;pwd = "Z3r0P4ss" +;opt[12] = true ; PDO::ATTR_PERSISTENT +;[model] ; example of DB configuration for SQLite -;model = zerobin_db -;model_options["dsn"] = "sqlite:" PATH "data"/db.sq3" -;model_options["usr"] = null -;model_options["pwd"] = null -;model_options["opt"] = null +;[model_options] +;class = zerobin_db +;dsn = "sqlite:" PATH "data/db.sq3" +;usr = null +;pwd = null +;opt[12] = true ; PDO::ATTR_PERSISTENT + diff --git a/lib/zerobin.php b/lib/zerobin.php index 899948f4..17581004 100644 --- a/lib/zerobin.php +++ b/lib/zerobin.php @@ -103,8 +103,8 @@ class zerobin ); } - $this->_conf = parse_ini_file(PATH . 'cfg/conf.ini'); - $this->_model = $this->_conf['model']; + $this->_conf = parse_ini_file(PATH . 'cfg/conf.ini', true); + $this->_model = $this->_conf['model']['class']; } /** @@ -117,7 +117,10 @@ class zerobin { // if needed, initialize the model if(is_string($this->_model)) { - $this->_model = forward_static_call(array($this->_model, 'getInstance'), $this->_conf['model_options']); + $this->_model = forward_static_call( + array($this->_model, 'getInstance'), + $this->_conf['model_options'] + ); } return $this->_model; } @@ -129,7 +132,7 @@ class zerobin * data (mandatory) = json encoded SJCL encrypted text (containing keys: iv,salt,ct) * * All optional data will go to meta information: - * expire (optional) = expiration delay (never,10min,1hour,1day,1month,1year,burn) (default:never) + * expire (optional) = expiration delay (never,5min,10min,1hour,1day,1week,1month,1year,burn) (default:never) * opendiscusssion (optional) = is the discussion allowed on this paste ? (0/1) (default:0) * nickname (optional) = in discussion, encoded SJCL encrypted text nickname of author of comment (containing keys: iv,salt,ct) * parentid (optional) = in discussion, which comment this comment replies to. @@ -143,18 +146,30 @@ class zerobin header('Content-type: application/json'); $error = false; - // Make sure last paste from the IP address was more than 10 seconds ago. - trafficlimiter::setLimit($this->_conf['traffic_limit']); - trafficlimiter::setPath($this->_conf['traffic_dir']); + // Make sure last paste from the IP address was more than X seconds ago. + trafficlimiter::setLimit($this->_conf['traffic']['limit']); + trafficlimiter::setPath($this->_conf['traffic']['dir']); if ( !trafficlimiter::canPass($_SERVER['REMOTE_ADDR']) - ) $this->_return_message(1, 'Please wait 10 seconds between each post.'); + ) $this->_return_message( + 1, + 'Please wait ' . + $this->_conf['traffic']['limit'] . + ' seconds between each post.' + ); // Make sure content is not too big. $data = $_POST['data']; if ( - strlen($data) > 2000000 - ) $this->_return_message(1, 'Paste is limited to 2 MB of encrypted data.'); + strlen($data) > $this->_conf['main']['sizelimit'] + ) $this->_return_message( + 1, + 'Paste is limited to ' . + $this->_conf['main']['sizelimit'] . + ' ' . + filter::size_humanreadable($this->_conf['main']['sizelimit']) . + ' of encrypted data.' + ); // Make sure format is correct. if (!sjcl::isValid($data)) $this->_return_message(1, 'Invalid data.'); @@ -167,6 +182,12 @@ class zerobin { switch ($_POST['expire']) { + case 'burn': + $meta['burnafterreading'] = true; + break; + case '5min': + $meta['expire_date'] = time()+5*60; + break; case '10min': $meta['expire_date'] = time()+10*60; break; @@ -176,19 +197,19 @@ class zerobin case '1day': $meta['expire_date'] = time()+24*60*60; break; + case '1week': + $meta['expire_date'] = time()+7*24*60*60; + break; case '1month': $meta['expire_date'] = strtotime('+1 month'); break; case '1year': $meta['expire_date'] = strtotime('+1 year'); - break; - case 'burn': - $meta['burnafterreading'] = true; } } // Read open discussion flag. - if (!empty($_POST['opendiscussion'])) + if ($this->_conf['main']['opendiscussion'] && !empty($_POST['opendiscussion'])) { $opendiscussion = $_POST['opendiscussion']; if ($opendiscussion != 0) @@ -381,6 +402,7 @@ class zerobin // We escape it here because ENT_NOQUOTES can't be used in RainTPL templates. $page->assign('CIPHERDATA', htmlspecialchars($this->_data, ENT_NOQUOTES)); $page->assign('ERRORMESSAGE', $this->_error); + $page->assign('OPENDISCUSSION', $this->_conf['main']['opendiscussion']); $page->assign('VERSION', self::VERSION); $page->draw('page'); } diff --git a/lib/zerobin/abstract.php b/lib/zerobin/abstract.php index 89794eff..118f4741 100644 --- a/lib/zerobin/abstract.php +++ b/lib/zerobin/abstract.php @@ -49,7 +49,7 @@ abstract class zerobin_abstract * * @access public * @static - * @return zerobin + * @return zerobin_abstract */ abstract public static function getInstance($options); diff --git a/lib/zerobin/data.php b/lib/zerobin/data.php index e9a5020b..ed326f22 100644 --- a/lib/zerobin/data.php +++ b/lib/zerobin/data.php @@ -29,9 +29,9 @@ class zerobin_data extends zerobin_abstract * * @access public * @static - * @return zerobin + * @return zerobin_data */ - public static function getInstance($options) + public static function getInstance($options = null) { // if given update the data directory if ( diff --git a/lib/zerobin/db.php b/lib/zerobin/db.php index aee7f61e..6bc2bcb8 100644 --- a/lib/zerobin/db.php +++ b/lib/zerobin/db.php @@ -17,6 +17,13 @@ */ class zerobin_db extends zerobin_abstract { + /* + * @access private + * @static + * @var array to cache select queries + */ + private static $_cache = array(); + /* * @access private * @static @@ -24,31 +31,137 @@ class zerobin_db extends zerobin_abstract */ private static $_db; + /* + * @access private + * @static + * @var string table prefix + */ + private static $_prefix = ''; + + /* + * @access private + * @static + * @var string database type + */ + private static $_type = ''; + /** * get instance of singleton * * @access public * @static - * @return zerobin + * @throws Exception + * @return zerobin_db */ - public static function getInstance($options) + public static function getInstance($options = null) { // if needed initialize the singleton if(null === self::$_instance) { parent::$_instance = new self; } - if ( - is_array($options) && - array_key_exists('dsn', $options) && - array_key_exists('usr', $options) && - array_key_exists('pwd', $options) && - array_key_exists('opt', $options) - ) self::$_db = new PDO( - $options['dsn'], - $options['usr'], - $options['pwd'], - $options['opt'] - ); + + if (is_array($options)) + { + // set table prefix if given + if (array_key_exists('tbl', $options)) self::$_prefix = $options['tbl']; + + // initialize the db connection with new options + if ( + array_key_exists('dsn', $options) && + array_key_exists('usr', $options) && + array_key_exists('pwd', $options) && + array_key_exists('opt', $options) + ) + { + self::$_db = new PDO( + $options['dsn'], + $options['usr'], + $options['pwd'], + $options['opt'] + ); + + // check if the database contains the required tables + self::$_type = strtolower( + substr($options['dsn'], 0, strpos($options['dsn'], ':')) + ); + switch(self::$_type) + { + case 'ibm': + $sql = 'SELECT tabname FROM SYSCAT.TABLES '; + break; + case 'informix': + $sql = 'SELECT tabname FROM systables '; + break; + case 'mssql': + $sql = "SELECT name FROM sysobjects " + . "WHERE type = 'U' ORDER BY name"; + break; + case 'mysql': + $sql = 'SHOW TABLES'; + break; + case 'oci': + $sql = 'SELECT table_name FROM all_tables'; + break; + case 'pgsql': + $sql = "SELECT c.relname AS table_name " + . "FROM pg_class c, pg_user u " + . "WHERE c.relowner = u.usesysid AND c.relkind = 'r' " + . "AND NOT EXISTS (SELECT 1 FROM pg_views WHERE viewname = c.relname) " + . "AND c.relname !~ '^(pg_|sql_)' " + . "UNION " + . "SELECT c.relname AS table_name " + . "FROM pg_class c " + . "WHERE c.relkind = 'r' " + . "AND NOT EXISTS (SELECT 1 FROM pg_views WHERE viewname = c.relname) " + . "AND NOT EXISTS (SELECT 1 FROM pg_user WHERE usesysid = c.relowner) " + . "AND c.relname !~ '^pg_'"; + break; + case 'sqlite': + $sql = "SELECT name FROM sqlite_master WHERE type='table' " + . "UNION ALL SELECT name FROM sqlite_temp_master " + . "WHERE type='table' ORDER BY name"; + break; + default: + throw new Exception( + 'PDO type ' . + self::$_type . + ' is currently not supported.' + ); + } + $statement = self::$_db->query($sql); + $tables = $statement->fetchAll(PDO::FETCH_COLUMN, 0); + + // create paste table if needed + if (!array_key_exists(self::$_prefix . 'paste', $tables)) + { + self::$_db->exec( + 'CREATE TABLE ' . self::$_prefix . 'paste ( ' . + 'dataid CHAR(16), ' . + 'data TEXT, ' . + 'postdate INT, ' . + 'expiredate INT, ' . + 'opendiscussion INT, ' . + 'burnafterreading INT );' + ); + } + + // create comment table if needed + if (!array_key_exists(self::$_prefix . 'comment', $tables)) + { + self::$_db->exec( + 'CREATE TABLE ' . self::$_prefix . 'comment ( ' . + 'dataid CHAR(16), ' . + 'pasteid CHAR(16), ' . + 'parentid CHAR(16), ' . + 'data TEXT, ' . + 'nickname VARCHAR(255), ' . + 'vizhash TEXT, ' . + 'postdate INT );' + ); + } + } + } + return parent::$_instance; } @@ -58,10 +171,27 @@ class zerobin_db extends zerobin_abstract * @access public * @param string $pasteid * @param array $paste - * @return int|false + * @return bool */ public function create($pasteid, $paste) { + if ( + !array_key_exists('opendiscussion', $paste['meta']) + ) $paste['meta']['opendiscussion'] = false; + if ( + !array_key_exists('burnafterreading', $paste['meta']) + ) $paste['meta']['burnafterreading'] = false; + return self::_exec( + 'INSERT INTO ' . self::$_prefix . 'paste VALUES(?,?,?,?,?,?)', + array( + $pasteid, + $paste['data'], + $paste['meta']['postdate'], + $paste['meta']['expire_date'], + (int) $paste['meta']['opendiscussion'], + (int) $paste['meta']['burnafterreading'], + ) + ); } /** @@ -73,6 +203,27 @@ class zerobin_db extends zerobin_abstract */ public function read($pasteid) { + if ( + !array_key_exists($pasteid, self::$_cache) + ) self::$_cache[$pasteid] = self::_select( + 'SELECT * FROM ' . self::$_prefix . 'paste WHERE dataid = ?', + array($pasteid), true + ); + + // create object + $paste = new stdClass; + $paste->data = self::$_cache[$pasteid]['data']; + $paste->meta = new stdClass; + $paste->meta->postdate = (int) self::$_cache[$pasteid]['postdate']; + $paste->meta->expire_date = (int) self::$_cache[$pasteid]['expiredate']; + if ( + self::$_cache[$pasteid]['opendiscussion'] + ) $paste->meta->opendiscussion = true; + if ( + self::$_cache[$pasteid]['burnafterreading'] + ) $paste->meta->burnafterreading = true; + + return $paste; } /** @@ -84,6 +235,14 @@ class zerobin_db extends zerobin_abstract */ public function delete($pasteid) { + self::_exec( + 'DELETE FROM ' . self::$_prefix . 'paste WHERE dataid = ?', + array($pasteid) + ); + self::_exec( + 'DELETE FROM ' . self::$_prefix . 'comment WHERE pasteid = ?', + array($pasteid) + ); } /** @@ -95,6 +254,13 @@ class zerobin_db extends zerobin_abstract */ public function exists($pasteid) { + if ( + !array_key_exists($pasteid, self::$_cache) + ) self::$_cache[$pasteid] = self::_select( + 'SELECT * FROM ' . self::$_prefix . 'paste WHERE dataid = ?', + array($pasteid), true + ); + return (bool) self::$_cache[$pasteid]; } /** @@ -109,6 +275,18 @@ class zerobin_db extends zerobin_abstract */ public function createComment($pasteid, $parentid, $commentid, $comment) { + return self::_exec( + 'INSERT INTO ' . self::$_prefix . 'comment VALUES(?,?,?,?,?,?,?)', + array( + $pasteid, + $parentid, + $commentid, + $comment['data'], + $comment['meta']['nickname'], + $comment['meta']['vizhash'], + $comment['meta']['postdate'], + ) + ); } /** @@ -120,6 +298,33 @@ class zerobin_db extends zerobin_abstract */ public function readComments($pasteid) { + $rows = self::_select( + 'SELECT * FROM ' . self::$_prefix . 'comment WHERE pasteid = ?', + array($pasteid) + ); + + // create object + $commentTemplate = new stdClass; + $commentTemplate->meta = new stdClass; + + // create comment list + $comments = array(); + if (count($rows)) + { + foreach ($rows as $row) + { + $i = (int) $row['postdate']; + $comments[$i] = clone $commentTemplate; + $comments[$i]->data = $row['data']; + $comments[$i]->meta->nickname = $row['nickname']; + $comments[$i]->meta->vizhash = $row['vizhash']; + $comments[$i]->meta->postdate = $i; + $comments[$i]->meta->commentid = $row['dataid']; + $comments[$i]->meta->parentid = $row['parentid']; + } + ksort($comments); + } + return $comments; } /** @@ -133,5 +338,50 @@ class zerobin_db extends zerobin_abstract */ public function existsComment($pasteid, $parentid, $commentid) { + return (bool) self::_select( + 'SELECT dataid FROM ' . self::$_prefix . 'comment ' . + 'WHERE pasteid = ? AND parentid = ? AND dataid = ?', + array($pasteid, $parentid, $commentid), true + ); + } + + /** + * execute a statement + * + * @access private + * @static + * @param string $sql + * @param array $params + * @throws PDOException + * @return array + */ + private static function _exec($sql, array $params) + { + $statement = self::$_db->prepare($sql); + $result = $statement->execute($params); + $statement->closeCursor(); + return $result; + } + + /** + * run a select statement + * + * @access private + * @static + * @param string $sql + * @param array $params + * @param bool $firstOnly if only the first row should be returned + * @throws PDOException + * @return array + */ + private static function _select($sql, array $params, $firstOnly = false) + { + $statement = self::$_db->prepare($sql); + $statement->execute($params); + $result = $firstOnly ? + $statement->fetch(PDO::FETCH_ASSOC) : + $statement->fetchAll(PDO::FETCH_ASSOC); + $statement->closeCursor(); + return $result; } } diff --git a/tpl/page.html b/tpl/page.html index 442f0e00..cdc6c38f 100644 --- a/tpl/page.html +++ b/tpl/page.html @@ -48,9 +48,11 @@