Refactoring of code base - modularized code, introduced configuration, started working on a PDO based DB connector

This commit is contained in:
Simon Rupf 2012-04-29 19:15:06 +02:00
parent 241c75a5d5
commit ba90d0cae2
10 changed files with 1170 additions and 388 deletions

31
cfg/conf.ini Normal file
View file

@ -0,0 +1,31 @@
; ZeroBin
;
; a zero-knowledge paste bin
;
; @link http://sebsauvage.net/wiki/doku.php?id=php:zerobin
; @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
; @license http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
; @version 0.15
; timelimit between calls from the same IP address in seconds
traffic_limit = 10
traffic_dir = PATH "data"
; 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"
; 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
; 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

351
index.php
View file

@ -1,339 +1,16 @@
<?php <?php
/* /**
ZeroBin - a zero-knowledge paste bin * ZeroBin
Please see project page: http://sebsauvage.net/wiki/doku.php?id=php:zerobin *
*/ * a zero-knowledge paste bin
$VERSION='Alpha 0.15'; *
if (version_compare(PHP_VERSION, '5.2.6') < 0) die('ZeroBin requires php 5.2.6 or above to work. Sorry.'); * @link http://sebsauvage.net/wiki/doku.php?id=php:zerobin
require_once "lib/vizhash_gd_zero.php"; * @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
* @license http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
* @version 0.15
*/
// In case stupid admin has left magic_quotes enabled in php.ini: // change this, if your php files and data is outside of your webservers document root
if (get_magic_quotes_gpc()) define('PATH', '');
{ require_once PATH . 'lib/zerobin.php';
function stripslashes_deep($value) { $value = is_array($value) ? array_map('stripslashes_deep', $value) : stripslashes($value); return $value; } new zerobin;
$_POST = array_map('stripslashes_deep', $_POST);
$_GET = array_map('stripslashes_deep', $_GET);
$_COOKIE = array_map('stripslashes_deep', $_COOKIE);
}
// trafic_limiter : Make sure the IP address makes at most 1 request every 10 seconds.
// Will return false if IP address made a call less than 10 seconds ago.
function trafic_limiter_canPass($ip)
{
$tfilename='./data/trafic_limiter.php';
if (!is_file($tfilename))
{
file_put_contents($tfilename,"<?php\n\$GLOBALS['trafic_limiter']=array();\n?>");
chmod($tfilename,0705);
}
require $tfilename;
$tl=$GLOBALS['trafic_limiter'];
if (!empty($tl[$ip]) && ($tl[$ip]+10>=time()))
{
return false;
// FIXME: purge file of expired IPs to keep it small
}
$tl[$ip]=time();
file_put_contents($tfilename, "<?php\n\$GLOBALS['trafic_limiter']=".var_export($tl,true).";\n?>");
return true;
}
/* Convert paste id to storage path.
The idea is to creates subdirectories in order to limit the number of files per directory.
(A high number of files in a single directory can slow things down.)
eg. "f468483c313401e8" will be stored in "data/f4/68/f468483c313401e8"
High-trafic websites may want to deepen the directory structure (like Squid does).
eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/'
*/
function dataid2path($dataid)
{
return 'data/'.substr($dataid,0,2).'/'.substr($dataid,2,2).'/';
}
/* Convert paste id to discussion storage path.
eg. 'e3570978f9e4aa90' --> 'data/e3/57/e3570978f9e4aa90.discussion/'
*/
function dataid2discussionpath($dataid)
{
return dataid2path($dataid).$dataid.'.discussion/';
}
// Checks if a json string is a proper SJCL encrypted message.
// False if format is incorrect.
function validSJCL($jsonstring)
{
$accepted_keys=array('iv','salt','ct');
// Make sure content is valid json
$decoded = json_decode($jsonstring);
if ($decoded==null) return false;
$decoded = (array)$decoded;
// Make sure required fields are present and that they are base64 data.
foreach($accepted_keys as $k)
{
if (!array_key_exists($k,$decoded)) { return false; }
if (base64_decode($decoded[$k],$strict=true)==null) { return false; }
}
// Make sure no additionnal keys were added.
if (count(array_intersect(array_keys($decoded),$accepted_keys))!=3) { return false; }
// FIXME: Reject data if entropy is too low ?
// Make sure some fields have a reasonable size.
if (strlen($decoded['iv'])>24) return false;
if (strlen($decoded['salt'])>14) return false;
return true;
}
// Delete a paste and its discussion.
// Input: $pasteid : the paste identifier.
function deletePaste($pasteid)
{
// Delete the paste itself
unlink(dataid2path($pasteid).$pasteid);
// Delete discussion if it exists.
$discdir = dataid2discussionpath($pasteid);
if (is_dir($discdir))
{
// Delete all files in discussion directory
$dhandle = opendir($discdir);
while (false !== ($filename = readdir($dhandle)))
{
if (is_file($discdir.$filename)) unlink($discdir.$filename);
}
closedir($dhandle);
// Delete the discussion directory.
rmdir($discdir);
}
}
if (!empty($_POST['data'])) // Create new paste/comment
{
/* POST contains:
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)
opendiscusssion (optional) = is the discussion allowed on this paste ? (0/1) (default:0)
nickname (optional) = son encoded SJCL encrypted text nickname of author of comment (containing keys: iv,salt,ct)
parentid (optional) = in discussion, which comment this comment replies to.
pasteid (optional) = in discussion, which paste this comment belongs to.
*/
header('Content-type: application/json');
$error = false;
// Create storage directory if it does not exist.
if (!is_dir('data'))
{
mkdir('data',0705);
file_put_contents('data/.htaccess',"Allow from none\nDeny from all\n");
}
// Make sure last paste from the IP address was more than 10 seconds ago.
if (!trafic_limiter_canPass($_SERVER['REMOTE_ADDR']))
{ echo json_encode(array('status'=>1,'message'=>'Please wait 10 seconds between each post.')); exit; }
// Make sure content is not too big.
$data = $_POST['data'];
if (strlen($data)>2000000)
{ echo json_encode(array('status'=>1,'message'=>'Paste is limited to 2 Mb of encrypted data.')); exit; }
// Make sure format is correct.
if (!validSJCL($data))
{ echo json_encode(array('status'=>1,'message'=>'Invalid data.')); exit; }
// Read additional meta-information.
$meta=array();
// Read expiration date
if (!empty($_POST['expire']))
{
$expire=$_POST['expire'];
if ($expire=='10min') $meta['expire_date']=time()+10*60;
elseif ($expire=='1hour') $meta['expire_date']=time()+60*60;
elseif ($expire=='1day') $meta['expire_date']=time()+24*60*60;
elseif ($expire=='1month') $meta['expire_date']=time()+30*24*60*60; // Well this is not *exactly* one month, it's 30 days.
elseif ($expire=='1year') $meta['expire_date']=time()+365*24*60*60;
elseif ($expire=='burn') $meta['burnafterreading']=true;
}
// Read open discussion flag
if (!empty($_POST['opendiscussion']))
{
$opendiscussion = $_POST['opendiscussion'];
if ($opendiscussion!='0' && $opendiscussion!='1') { $error=true; }
if ($opendiscussion!='0') { $meta['opendiscussion']=true; }
}
// You can't have an open discussion on a "Burn after reading" paste:
if (isset($meta['burnafterreading'])) unset($meta['opendiscussion']);
// Optional nickname for comments
if (!empty($_POST['nickname']))
{
$nick = $_POST['nickname'];
if (!validSJCL($nick))
{
$error=true;
}
else
{
$meta['nickname']=$nick;
// Generation of the anonymous avatar (Vizhash):
// If a nickname is provided, we generate a Vizhash.
// (We assume that if the user did not enter a nickname, he/she wants
// to be anonymous and we will not generate the vizhash.)
$vz = new vizhash16x16();
$pngdata = $vz->generate($_SERVER['REMOTE_ADDR']);
if ($pngdata!='') $meta['vizhash'] = 'data:image/png;base64,'.base64_encode($pngdata);
// Once the avatar is generated, we do not keep the IP address, nor its hash.
}
}
if ($error)
{
echo json_encode(array('status'=>1,'message'=>'Invalid data.'));
exit;
}
// Add post date to meta.
$meta['postdate']=time();
// We just want a small hash to avoid collisions: Half-MD5 (64 bits) will do the trick
$dataid = substr(hash('md5',$data),0,16);
$is_comment = (!empty($_POST['parentid']) && !empty($_POST['pasteid'])); // Is this post a comment ?
$storage = array('data'=>$data);
if (count($meta)>0) $storage['meta'] = $meta; // Add meta-information only if necessary.
if ($is_comment) // The user posts a comment.
{
$pasteid = $_POST['pasteid'];
$parentid = $_POST['parentid'];
if (!preg_match('/[a-f\d]{16}/',$pasteid)) { echo json_encode(array('status'=>1,'message'=>'Invalid data.')); exit; }
if (!preg_match('/[a-f\d]{16}/',$parentid)) { echo json_encode(array('status'=>1,'message'=>'Invalid data.')); exit; }
unset($storage['expire_date']); // Comment do not expire (it's the paste that expires)
unset($storage['opendiscussion']);
// Make sure paste exists.
$storagedir = dataid2path($pasteid);
if (!is_file($storagedir.$pasteid)) { echo json_encode(array('status'=>1,'message'=>'Invalid data.')); exit; }
// Make sure the discussion is opened in this paste.
$paste=json_decode(file_get_contents($storagedir.$pasteid));
if (!$paste->meta->opendiscussion) { echo json_encode(array('status'=>1,'message'=>'Invalid data.')); exit; }
$discdir = dataid2discussionpath($pasteid);
$filename = $pasteid.'.'.$dataid.'.'.$parentid;
if (!is_dir($discdir)) mkdir($discdir,$mode=0705,$recursive=true);
if (is_file($discdir.$filename)) // Oups... improbable collision.
{
echo json_encode(array('status'=>1,'message'=>'You are unlucky. Try again.'));
exit;
}
file_put_contents($discdir.$filename,json_encode($storage));
echo json_encode(array('status'=>0,'id'=>$dataid)); // 0 = no error
exit;
}
else // a standard paste.
{
$storagedir = dataid2path($dataid);
if (!is_dir($storagedir)) mkdir($storagedir,$mode=0705,$recursive=true);
if (is_file($storagedir.$dataid)) // Oups... improbable collision.
{
echo json_encode(array('status'=>1,'message'=>'You are unlucky. Try again.'));
exit;
}
// New paste
file_put_contents($storagedir.$dataid,json_encode($storage));
echo json_encode(array('status'=>0,'id'=>$dataid)); // 0 = no error
exit;
}
echo json_encode(array('status'=>1,'message'=>'Server error.'));
exit;
}
$CIPHERDATA='';
$ERRORMESSAGE='';
if (!empty($_SERVER['QUERY_STRING'])) // Display an existing paste.
{
$dataid = $_SERVER['QUERY_STRING'];
if (preg_match('/[a-f\d]{16}/',$dataid)) // Is this a valid paste identifier ?
{
$filename = dataid2path($dataid).$dataid;
if (is_file($filename)) // Check that paste exists.
{
// Get the paste itself.
$paste=json_decode(file_get_contents($filename));
// See if paste has expired.
if (isset($paste->meta->expire_date) && $paste->meta->expire_date<time())
{
deletePaste($dataid); // Delete the paste
$ERRORMESSAGE='Paste does not exist or has expired.';
}
if ($ERRORMESSAGE=='') // If no error, return the paste.
{
// We kindly provide the remaining time before expiration (in seconds)
if (property_exists($paste->meta, 'expire_date')) $paste->meta->remaining_time = $paste->meta->expire_date - time();
$messages = array($paste); // The paste itself is the first in the list of encrypted messages.
// If it's a discussion, get all comments.
if (property_exists($paste->meta, 'opendiscussion') && $paste->meta->opendiscussion)
{
$comments=array();
$datadir = dataid2discussionpath($dataid);
if (!is_dir($datadir)) mkdir($datadir,$mode=0705,$recursive=true);
$dhandle = opendir($datadir);
while (false !== ($filename = readdir($dhandle)))
{
if (is_file($datadir.$filename))
{
$comment=json_decode(file_get_contents($datadir.$filename));
// Filename is in the form pasteid.commentid.parentid:
// - pasteid is the paste this reply belongs to.
// - commentid is the comment identifier itself.
// - parentid is the comment this comment replies to (It can be pasteid)
$items=explode('.',$filename);
$comment->meta->commentid=$items[1]; // Add some meta information not contained in file.
$comment->meta->parentid=$items[2];
$comments[$comment->meta->postdate]=$comment; // Store in table
}
}
closedir($dhandle);
ksort($comments); // Sort comments by date, oldest first.
$messages = array_merge($messages, $comments);
}
$CIPHERDATA = json_encode($messages);
// If the paste was meant to be read only once, delete it.
if (property_exists($paste->meta, 'burnafterreading') && $paste->meta->burnafterreading) deletePaste($dataid);
}
}
else
{
$ERRORMESSAGE='Paste does not exist or has expired.';
}
}
}
require_once "lib/rain.tpl.class.php";
header('Content-Type: text/html; charset=utf-8');
$page = new RainTPL;
$page->assign('CIPHERDATA',htmlspecialchars($CIPHERDATA,ENT_NOQUOTES)); // We escape it here because ENT_NOQUOTES can't be used in RainTPL templates.
$page->assign('VERSION',$VERSION);
$page->assign('ERRORMESSAGE',$ERRORMESSAGE);
$page->draw('page');
?>

34
lib/filter.php Normal file
View file

@ -0,0 +1,34 @@
<?php
/**
* ZeroBin
*
* a zero-knowledge paste bin
*
* @link http://sebsauvage.net/wiki/doku.php?id=php:zerobin
* @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
* @license http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
* @version 0.15
*/
/**
* filter
*
* Provides data filtering functions.
*/
class filter
{
/**
* strips slashes deeply
*
* @access public
* @static
* @param mixed $value
* @return mixed
*/
public static function stripslashes_deep($value)
{
return is_array($value) ?
array_map('filter::stripslashes_deep', $value) :
stripslashes($value);
}
}

View file

@ -21,7 +21,7 @@ class RainTPL{
* *
* @var string * @var string
*/ */
static $tpl_dir = "tpl/"; static $tpl_dir = 'tpl/';
/** /**
@ -29,7 +29,7 @@ class RainTPL{
* *
* @var string * @var string
*/ */
static $cache_dir = "tmp/"; static $cache_dir = 'tmp/';
/** /**
@ -257,9 +257,9 @@ class RainTPL{
$tpl_basename = basename( $tpl_name ); // template basename $tpl_basename = basename( $tpl_name ); // template basename
$tpl_basedir = strpos($tpl_name,"/") ? dirname($tpl_name) . '/' : null; // template basedirectory $tpl_basedir = strpos($tpl_name,"/") ? dirname($tpl_name) . '/' : null; // template basedirectory
$tpl_dir = self::$tpl_dir . $tpl_basedir; // template directory $tpl_dir = PATH . self::$tpl_dir . $tpl_basedir; // template directory
$this->tpl['tpl_filename'] = $tpl_dir . $tpl_basename . '.' . self::$tpl_ext; // template filename $this->tpl['tpl_filename'] = $tpl_dir . $tpl_basename . '.' . self::$tpl_ext; // template filename
$temp_compiled_filename = self::$cache_dir . $tpl_basename . "." . md5( $tpl_dir . serialize(self::$config_name_sum)); $temp_compiled_filename = PATH . self::$cache_dir . $tpl_basename . "." . md5( $tpl_dir . serialize(self::$config_name_sum));
$this->tpl['compiled_filename'] = $temp_compiled_filename . '.rtpl.php'; // cache filename $this->tpl['compiled_filename'] = $temp_compiled_filename . '.rtpl.php'; // cache filename
$this->tpl['cache_filename'] = $temp_compiled_filename . '.s_' . $this->cache_id . '.rtpl.php'; // static cache filename $this->tpl['cache_filename'] = $temp_compiled_filename . '.s_' . $this->cache_id . '.rtpl.php'; // static cache filename
@ -271,7 +271,7 @@ class RainTPL{
// file doesn't exsist, or the template was updated, Rain will compile the template // file doesn't exsist, or the template was updated, Rain will compile the template
if( !file_exists( $this->tpl['compiled_filename'] ) || ( self::$check_template_update && filemtime($this->tpl['compiled_filename']) < filemtime( $this->tpl['tpl_filename'] ) ) ){ if( !file_exists( $this->tpl['compiled_filename'] ) || ( self::$check_template_update && filemtime($this->tpl['compiled_filename']) < filemtime( $this->tpl['tpl_filename'] ) ) ){
$this->compileFile( $tpl_basename, $tpl_basedir, $this->tpl['tpl_filename'], self::$cache_dir, $this->tpl['compiled_filename'] ); $this->compileFile( $tpl_basename, $tpl_basedir, $this->tpl['tpl_filename'], PATH . self::$cache_dir, $this->tpl['compiled_filename'] );
return true; return true;
} }
$this->tpl['checked'] = true; $this->tpl['checked'] = true;
@ -611,7 +611,7 @@ class RainTPL{
if( self::$path_replace ){ if( self::$path_replace ){
$tpl_dir = self::$base_url . self::$tpl_dir . $tpl_basedir; $tpl_dir = self::$base_url . PATH . self::$tpl_dir . $tpl_basedir;
// reduce the path // reduce the path
$path = $this->reduce_path($tpl_dir); $path = $this->reduce_path($tpl_dir);

64
lib/sjcl.php Normal file
View file

@ -0,0 +1,64 @@
<?php
/**
* ZeroBin
*
* a zero-knowledge paste bin
*
* @link http://sebsauvage.net/wiki/doku.php?id=php:zerobin
* @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
* @license http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
* @version 0.15
*/
/**
* sjcl
*
* Provides SJCL validation function.
*/
class sjcl
{
/**
* SJCL validator
*
* Checks if a json string is a proper SJCL encrypted message.
*
* @access public
* @static
* @param string $encoded JSON
* @return bool
*/
public static function isValid($encoded)
{
$accepted_keys = array('iv','salt','ct');
// Make sure content is valid json
$decoded = json_decode($encoded);
if (is_null($decoded)) return false;
$decoded = (array) $decoded;
// Make sure required fields are present and contain base64 data.
foreach($accepted_keys as $k)
{
if (!array_key_exists($k, $decoded)) return false;
if (is_null(base64_decode($decoded[$k], $strict=true))) return false;
}
// Make sure no additionnal keys were added.
if (
count(
array_intersect(
array_keys($decoded),
$accepted_keys
)
) != 3
) return false;
// FIXME: Reject data if entropy is too low?
// Make sure some fields have a reasonable size.
if (strlen($decoded['iv']) > 24) return false;
if (strlen($decoded['salt']) > 14) return false;
return true;
}
}

111
lib/traffic_limiter.php Normal file
View file

@ -0,0 +1,111 @@
<?php
/**
* ZeroBin
*
* a zero-knowledge paste bin
*
* @link http://sebsauvage.net/wiki/doku.php?id=php:zerobin
* @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
* @license http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
* @version 0.15
*/
/**
* traffic_limiter
*
* Handles traffic limiting, so no user does more than one call per 10 seconds.
*/
class traffic_limiter
{
/**
* @access private
* @static
* @var int
*/
private static $_limit = 10;
/**
* @access private
* @static
* @var string
*/
private static $_path = 'data';
/**
* set the time limit in seconds
*
* @access public
* @static
* @param int $limit
* @return void
*/
public static function setLimit($limit)
{
self::$_limit = $limit;
}
/**
* set the path
*
* @access public
* @static
* @param string $path
* @return void
*/
public static function setPath($path)
{
self::$_path = $path;
}
/**
* traffic limiter
*
* Make sure the IP address makes at most 1 request every 10 seconds.
*
* @access public
* @static
* @param string $ip
* @return bool
*/
public static function canPass($ip)
{
if (!is_dir(self::$_path)) mkdir(self::$_path, 0705, true);
$file = self::$_path . '/traffic_limiter.php';
if (!is_file($file))
{
file_put_contents(
$file,
'<?php' . PHP_EOL .
'$GLOBALS[\'traffic_limiter\'] = array();' . PHP_EOL
);
chmod($file, 0705);
}
require $file;
$tl = $GLOBALS['traffic_limiter'];
// purge file of expired IPs to keep it small
foreach($tl as $key => $time)
{
if ($time + 10 < time())
{
unset($tl[$key]);
}
}
if (array_key_exists($ip, $tl) && ($tl[$ip] + 10 >= time()))
{
$result = false;
} else {
$tl[$ip] = time();
$result = true;
}
file_put_contents(
$file,
'<?php' . PHP_EOL .
'$GLOBALS[\'traffic_limiter\'] = ' .
var_export($tl, true) . ';' . PHP_EOL
);
return $result;
}
}

View file

@ -26,7 +26,7 @@ class vizhash16x16
// Read salt from file (and create it if does not exist). // Read salt from file (and create it if does not exist).
// The salt will make vizhash avatar unique on each ZeroBin installation // The salt will make vizhash avatar unique on each ZeroBin installation
// to prevent IP checking. // to prevent IP checking.
$saltfile = 'data/salt.php'; $saltfile = PATH . 'data/salt.php';
if (!is_file($saltfile)) if (!is_file($saltfile))
file_put_contents($saltfile,'<?php /* |'.$this->randomSalt().'| */ ?>'); file_put_contents($saltfile,'<?php /* |'.$this->randomSalt().'| */ ?>');
$items=explode('|',file_get_contents($saltfile)); $items=explode('|',file_get_contents($saltfile));

406
lib/zerobin.php Normal file
View file

@ -0,0 +1,406 @@
<?php
/**
* ZeroBin
*
* a zero-knowledge paste bin
*
* @link http://sebsauvage.net/wiki/doku.php?id=php:zerobin
* @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
* @license http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
* @version 0.15
*/
/**
* zerobin
*
* Controller, puts it all together.
*/
class zerobin
{
/*
* @const string version
*/
const VERSION = 'Alpha 0.15';
/**
* @access private
* @var array
*/
private $_conf = array(
'model' => 'zerobin_data',
);
/**
* @access private
* @var string
*/
private $_data = '';
/**
* @access private
* @var string
*/
private $_error = '';
/**
* @access private
* @var zerobin_data
*/
private $_model;
/**
* constructor
*
* initializes and runs ZeroBin
*
* @access public
*/
public function __construct()
{
if (version_compare(PHP_VERSION, '5.2.6') < 0)
die('ZeroBin requires php 5.2.6 or above to work. Sorry.');
// In case stupid admin has left magic_quotes enabled in php.ini.
if (get_magic_quotes_gpc())
{
require_once PATH . 'lib/filter.php';
$_POST = array_map('filter::stripslashes_deep', $_POST);
$_GET = array_map('filter::stripslashes_deep', $_GET);
$_COOKIE = array_map('filter::stripslashes_deep', $_COOKIE);
}
// Load config from ini file.
$this->_init();
// Create new paste or comment.
if (!empty($_POST['data']))
{
$this->_create();
}
// Display an existing paste.
elseif (!empty($_SERVER['QUERY_STRING']))
{
$this->_read();
}
// Display ZeroBin frontend
$this->_view();
}
/**
* initialize zerobin
*
* @access private
* @return void
*/
private function _init()
{
$this->_conf = parse_ini_file(PATH . 'cfg/conf.ini');
$this->_model = $this->_conf['model'];
}
/**
* get the model, create one if needed
*
* @access private
* @return zerobin_data
*/
private function _model()
{
// if needed, initialize the model
if(is_string($this->_model)) {
require_once PATH . 'lib/' . $this->_model . '.php';
$this->_model = forward_static_call(array($this->_model, 'getInstance'), $this->_conf['model_options']);
}
return $this->_model;
}
/**
* Store new paste or comment.
*
* POST contains:
* 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)
* 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.
* pasteid (optional) = in discussion, which paste this comment belongs to.
*
* @access private
* @return void
*/
private function _create()
{
header('Content-type: application/json');
$error = false;
// Make sure last paste from the IP address was more than 10 seconds ago.
require_once PATH . 'lib/traffic_limiter.php';
traffic_limiter::setLimit($this->_conf['traffic_limit']);
traffic_limiter::setPath($this->_conf['traffic_dir']);
if (
!traffic_limiter::canPass($_SERVER['REMOTE_ADDR'])
) $this->_return_message(1, 'Please wait 10 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.');
// Make sure format is correct.
require_once PATH . 'lib/sjcl.php';
if (!sjcl::isValid($data)) $this->_return_message(1, 'Invalid data.');
// Read additional meta-information.
$meta=array();
// Read expiration date
if (!empty($_POST['expire']))
{
switch ($_POST['expire'])
{
case '10min':
$meta['expire_date'] = time()+10*60;
break;
case '1hour':
$meta['expire_date'] = time()+60*60;
break;
case '1day':
$meta['expire_date'] = time()+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']))
{
$opendiscussion = $_POST['opendiscussion'];
if ($opendiscussion != 0)
{
if ($opendiscussion != 1) $error = true;
$meta['opendiscussion'] = true;
}
}
// You can't have an open discussion on a "Burn after reading" paste:
if (isset($meta['burnafterreading'])) unset($meta['opendiscussion']);
// Optional nickname for comments
if (!empty($_POST['nickname']))
{
// Generation of the anonymous avatar (Vizhash):
// If a nickname is provided, we generate a Vizhash.
// (We assume that if the user did not enter a nickname, he/she wants
// to be anonymous and we will not generate the vizhash.)
$nick = $_POST['nickname'];
if (!sjcl::isValid($nick))
{
$error = true;
}
else
{
require_once PATH . 'lib/vizhash_gd_zero.php';
$meta['nickname'] = $nick;
$vz = new vizhash16x16();
$pngdata = $vz->generate($_SERVER['REMOTE_ADDR']);
if ($pngdata != '')
{
$meta['vizhash'] = 'data:image/png;base64,' . base64_encode($pngdata);
}
// Once the avatar is generated, we do not keep the IP address, nor its hash.
}
}
if ($error) $this->_return_message(1, 'Invalid data.');
// Add post date to meta.
$meta['postdate'] = time();
// We just want a small hash to avoid collisions:
// Half-MD5 (64 bits) will do the trick
$dataid = substr(hash('md5', $data), 0, 16);
$storage = array('data' => $data);
// Add meta-information only if necessary.
if (count($meta)) $storage['meta'] = $meta;
// The user posts a comment.
if (
!empty($_POST['parentid']) &&
!empty($_POST['pasteid'])
)
{
$pasteid = $_POST['pasteid'];
$parentid = $_POST['parentid'];
if (
!preg_match('/[a-f\d]{16}/', $pasteid) ||
!preg_match('/[a-f\d]{16}/', $parentid)
) $this->_return_message(1, 'Invalid data.');
// Comments do not expire (it's the paste that expires)
unset($storage['expire_date']);
unset($storage['opendiscussion']);
// Make sure paste exists.
if (
!$this->_model()->exists($pasteid)
) $this->_return_message(1, 'Invalid data.');
// Make sure the discussion is opened in this paste.
$paste = $this->_model()->read($pasteid);
if (
!$paste->meta->opendiscussion
) $this->_return_message(1, 'Invalid data.');
// Check for improbable collision.
if (
$this->_model()->existsComment($pasteid, $parentid, $dataid)
) $this->_return_message(1, 'You are unlucky. Try again.');
// New comment
if (
$this->_model()->createComment($pasteid, $parentid, $dataid, $storage) === false
) $this->_return_message(1, 'Error saving comment. Sorry.');
// 0 = no error
$this->_return_message(0, $dataid);
}
// The user posts a standard paste.
else
{
// Check for improbable collision.
if (
$this->_model()->exists($dataid)
) $this->_return_message(1, 'You are unlucky. Try again.');
// New paste
if (
$this->_model()->create($dataid, $storage) === false
) $this->_return_message(1, 'Error saving paste. Sorry.');
// 0 = no error
$this->_return_message(0, $dataid);
}
$this->_return_message(1, 'Server error.');
}
/**
* Read an existing paste or comment.
*
* @access private
* @return void
*/
private function _read()
{
$dataid = $_SERVER['QUERY_STRING'];
// Is this a valid paste identifier?
if (preg_match('/[a-f\d]{16}/', $dataid))
{
// Check that paste exists.
if ($this->_model()->exists($dataid))
{
// Get the paste itself.
$paste = $this->_model()->read($dataid);
// See if paste has expired.
if (
isset($paste->meta->expire_date) &&
$paste->meta->expire_date < time()
)
{
// Delete the paste
$this->_model()->delete($dataid);
$this->_error = 'Paste does not exist or has expired.';
}
// If no error, return the paste.
else
{
// We kindly provide the remaining time before expiration (in seconds)
if (
property_exists($paste->meta, 'expire_date')
) $paste->meta->remaining_time = $paste->meta->expire_date - time();
// The paste itself is the first in the list of encrypted messages.
$messages = array($paste);
// If it's a discussion, get all comments.
if (
property_exists($paste->meta, 'opendiscussion') &&
$paste->meta->opendiscussion
)
{
$messages = array_merge(
$messages,
$this->_model()->readComments($dataid)
);
}
$this->_data = json_encode($messages);
// If the paste was meant to be read only once, delete it.
if (
property_exists($paste->meta, 'burnafterreading') &&
$paste->meta->burnafterreading
) $this->_model()->delete($dataid);
}
}
else
{
$this->_error = 'Paste does not exist or has expired.';
}
}
}
/**
* Display ZeroBin frontend.
*
* @access private
* @return void
*/
private function _view()
{
require_once PATH . 'lib/rain.tpl.class.php';
header('Content-Type: text/html; charset=utf-8');
$page = new RainTPL;
// 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('VERSION', self::VERSION);
$page->draw('page');
}
/**
* return JSON encoded message and exit
*
* @access private
* @param bool $status
* @param string $message
* @return void
*/
private function _return_message($status, $message)
{
$result = array('status' => $status);
if ($status)
{
$result['message'] = $message;
}
else
{
$result['id'] = $message;
}
exit(json_encode($result));
}
}

283
lib/zerobin_data.php Normal file
View file

@ -0,0 +1,283 @@
<?php
/**
* ZeroBin
*
* a zero-knowledge paste bin
*
* @link http://sebsauvage.net/wiki/doku.php?id=php:zerobin
* @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
* @license http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
* @version 0.15
*/
/**
* zerobin_data
*
* Model for data access, implemented as a singleton.
*/
class zerobin_data
{
/*
* @access private
* @static
* @var string directory where data is stored
*/
private static $_dir = 'data/';
/**
* singleton instance
*
* @access private
* @static
* @var zerobin
*/
private static $_instance = null;
/**
* enforce singleton, disable constructor
*
* Instantiate using {@link getInstance()}, zerobin is a singleton object.
*
* @access protected
*/
protected function __construct() {}
/**
* enforce singleton, disable cloning
*
* Instantiate using {@link getInstance()}, zerobin is a singleton object.
*
* @access private
*/
private function __clone() {}
/**
* get instance of singleton
*
* @access public
* @static
* @return zerobin
*/
public static function getInstance($options)
{
// if needed initialize the singleton
if(null === self::$_instance) {
self::$_instance = new self;
self::_init();
}
if (
is_array($options) &&
array_key_exists('dir', $options)
) self::$_dir = $options['dir'];
return self::$_instance;
}
/**
* Create a paste.
*
* @access public
* @param string $pasteid
* @param array $paste
* @return int|false
*/
public function create($pasteid, $paste)
{
$storagedir = self::_dataid2path($pasteid);
if (is_file($storagedir . $pasteid)) return false;
if (!is_dir($storagedir)) mkdir($storagedir, 0705, true);
return file_put_contents($storagedir . $pasteid, json_encode($paste));
}
/**
* Read a paste.
*
* @access public
* @param string $pasteid
* @return string
*/
public function read($pasteid)
{
if(!$this->exists($pasteid)) return json_decode(
'{"data":"","meta":{"burnafterreading":true,"postdate":0}}'
);
return json_decode(
file_get_contents(self::_dataid2path($pasteid) . $pasteid)
);
}
/**
* Delete a paste and its discussion.
*
* @access public
* @param string $pasteid
* @return void
*/
public function delete($pasteid)
{
// Delete the paste itself.
unlink(self::_dataid2path($pasteid) . $pasteid);
// Delete discussion if it exists.
$discdir = self::_dataid2discussionpath($pasteid);
if (is_dir($discdir))
{
// Delete all files in discussion directory
$dir = dir($discdir);
while (false !== ($filename = $dir->read()))
{
if (is_file($discdir.$filename)) unlink($discdir.$filename);
}
$dir->close();
// Delete the discussion directory.
rmdir($discdir);
}
}
/**
* Test if a paste exists.
*
* @access public
* @param string $dataid
* @return void
*/
public function exists($pasteid)
{
return is_file(self::_dataid2path($pasteid) . $pasteid);
}
/**
* Create a comment in a paste.
*
* @access public
* @param string $pasteid
* @param string $parentid
* @param string $commentid
* @param array $comment
* @return int|false
*/
public function createComment($pasteid, $parentid, $commentid, $comment)
{
$storagedir = self::_dataid2discussionpath($pasteid);
$filename = $pasteid . '.' . $commentid . '.' . $parentid;
if (is_file($storagedir . $filename)) return false;
if (!is_dir($storagedir)) mkdir($storagedir, 0705, true);
return file_put_contents($storagedir . $filename, json_encode($comment));
}
/**
* Read all comments of paste.
*
* @access public
* @param string $pasteid
* @return array
*/
public function readComments($pasteid)
{
$comments = array();
$discdir = self::_dataid2discussionpath($pasteid);
if (is_dir($discdir))
{
// Delete all files in discussion directory
$dir = dir($discdir);
while (false !== ($filename = $dir->read()))
{
// Filename is in the form pasteid.commentid.parentid:
// - pasteid is the paste this reply belongs to.
// - commentid is the comment identifier itself.
// - parentid is the comment this comment replies to (It can be pasteid)
if (is_file($discdir.$filename))
{
$comment = json_decode(file_get_contents($discdir.$filename));
$items = explode('.', $filename);
// Add some meta information not contained in file.
$comment->meta->commentid=$items[1];
$comment->meta->parentid=$items[2];
// Store in array
$comments[$comment->meta->postdate]=$comment;
}
}
$dir->close();
// Sort comments by date, oldest first.
ksort($comments);
}
return $comments;
}
/**
* Test if a comment exists.
*
* @access public
* @param string $dataid
* @param string $parentid
* @param string $commentid
* @return void
*/
public function existsComment($pasteid, $parentid, $commentid)
{
return is_file(
self::_dataid2discussionpath($pasteid) .
$pasteid . '.' . $dataid . '.' . $parentid
);
}
/**
* initialize zerobin
*
* @access private
* @static
* @return void
*/
private static function _init()
{
if (defined('PATH')) self::$_dir = PATH . self::$_dir;
// Create storage directory if it does not exist.
if (!is_dir(self::$_dir))
{
mkdir(self::$_dir, 0705);
file_put_contents(
self::$_dir . '.htaccess',
'Allow from none' . PHP_EOL .
'Deny from all'. PHP_EOL
);
}
}
/**
* Convert paste id to storage path.
*
* The idea is to creates subdirectories in order to limit the number of files per directory.
* (A high number of files in a single directory can slow things down.)
* eg. "f468483c313401e8" will be stored in "data/f4/68/f468483c313401e8"
* High-trafic websites may want to deepen the directory structure (like Squid does).
*
* eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/'
*
* @access private
* @static
* @param string $dataid
* @return void
*/
private static function _dataid2path($dataid)
{
return self::$_dir . substr($dataid,0,2) . '/' . substr($dataid,2,2) . '/';
}
/**
* Convert paste id to discussion storage path.
*
* eg. input 'e3570978f9e4aa90' --> output 'data/e3/57/e3570978f9e4aa90.discussion/'
*
* @access private
* @static
* @param string $dataid
* @return void
*/
private static function _dataid2discussionpath($dataid)
{
return self::_dataid2path($dataid) . $dataid . '.discussion/';
}
}

176
lib/zerobin_db.php Normal file
View file

@ -0,0 +1,176 @@
<?php
/**
* ZeroBin
*
* a zero-knowledge paste bin
*
* @link http://sebsauvage.net/wiki/doku.php?id=php:zerobin
* @copyright 2012 Sébastien SAUVAGE (sebsauvage.net)
* @license http://www.opensource.org/licenses/zlib-license.php The zlib/libpng License
* @version 0.15
*/
/**
* zerobin_db
*
* Model for DB access, implemented as a singleton.
*/
class zerobin_db
{
/*
* @access private
* @static
* @var PDO instance of database connection
*/
private static $_db;
/**
* singleton instance
*
* @access private
* @static
* @var zerobin
*/
private static $_instance = null;
/**
* enforce singleton, disable constructor
*
* Instantiate using {@link getInstance()}, zerobin is a singleton object.
*
* @access protected
*/
protected function __construct() {}
/**
* enforce singleton, disable cloning
*
* Instantiate using {@link getInstance()}, zerobin is a singleton object.
*
* @access private
*/
private function __clone() {}
/**
* get instance of singleton
*
* @access public
* @static
* @return zerobin
*/
public static function getInstance($options)
{
// if needed initialize the singleton
if(null === self::$_instance) {
self::$_instance = new self;
self::_init();
}
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']
);
return self::$_instance;
}
/**
* Create a paste.
*
* @access public
* @param string $pasteid
* @param array $paste
* @return int|false
*/
public function create($pasteid, $paste)
{
}
/**
* Read a paste.
*
* @access public
* @param string $pasteid
* @return string
*/
public function read($pasteid)
{
}
/**
* Delete a paste and its discussion.
*
* @access public
* @param string $pasteid
* @return void
*/
public function delete($pasteid)
{
}
/**
* Test if a paste exists.
*
* @access public
* @param string $dataid
* @return void
*/
public function exists($pasteid)
{
}
/**
* Create a comment in a paste.
*
* @access public
* @param string $pasteid
* @param string $parentid
* @param string $commentid
* @param array $comment
* @return int|false
*/
public function createComment($pasteid, $parentid, $commentid, $comment)
{
}
/**
* Read all comments of paste.
*
* @access public
* @param string $pasteid
* @return array
*/
public function readComments($pasteid)
{
}
/**
* Test if a comment exists.
*
* @access public
* @param string $dataid
* @param string $parentid
* @param string $commentid
* @return void
*/
public function existsComment($pasteid, $parentid, $commentid)
{
}
/**
* initialize zerobin
*
* @access private
* @static
* @return void
*/
private static function _init()
{
}
}