* Copyright 2001-2012 Strangecode, LLC
*
* This file is part of The Strangecode Codebase.
*
* The Strangecode Codebase is free software: you can redistribute it and/or
* modify it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your option)
* any later version.
*
* The Strangecode Codebase is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
* details.
*
* You should have received a copy of the GNU General Public License along with
* The Strangecode Codebase. If not, see .
*/
/**
* PEdit.inc.php
*
* PEdit provides a mechanism to store text in php variables
* which will be printed to the client browser under normal
* circumstances, but an authenticated user can 'edit' the document--
* data stored in vars will be shown in html form elements to be edited
* and saved. Posted data is stored in XML format in a specified data dir.
* A copy of the previous version is saved with the unix
* timestamp as part of the filename. This allows reverting to previous versions.
*
* To use, include this file, initialize variables,
* and call printing/editing functions where you want data and forms to
* show up.
*
* @author Quinn Comendant
* @concept Beau Smith
* @version 2.0
*
* Example of use:
// Initialize PEdit object.
require_once 'codebase/lib/PEdit.inc.php';
$pedit = new PEdit(array(
'data_dir' => COMMON_BASE . '/html/_pedit_data',
'authorized' => true,
));
// Setup content data types.
$pedit->set('title');
$pedit->set('content', array('type' => 'textarea'));
// After setting all parameters and data, load the data.
$pedit->start();
// Print content.
echo $pedit->get('title');
echo $pedit->get('content');
// Print additional PEdit functionality.
$pedit->formBegin();
$pedit->printAllForms();
$pedit->printVersions();
$pedit->formEnd();
*/
class PEdit
{
// PEdit object parameters.
protected $_params = array(
'data_dir' => '',
'character_set' => 'utf-8',
'versions_min_qty' => 20,
'versions_min_days' => 10,
);
protected $_data = array(); // Array to store loaded data.
protected $_data_file = ''; // Full file path to the pedit data file.
protected $_authorized = false; // User is authenticated to see extended functions.
protected $_data_loaded = false;
public $op = '';
/**
* Constructs a new PEdit object. Initializes what file is being operated with
* (PHP_SELF) and what that operation is. The two
* operations that actually modify data (save, restore) are treated differently
* than view operations (versions, view, default). They die redirect so you see
* the page you just modified.
*
* @access public
* @param optional array $params A hash containing connection parameters.
*/
public function __construct($params)
{
$this->setParam($params);
if ($this->getParam('authorized') === true) {
$this->_authorized = true;
}
// Setup PEAR XML libraries.
require_once 'XML/Serializer.php';
$this->xml_serializer = new XML_Serializer(array(
XML_SERIALIZER_OPTION_INDENT => '',
XML_SERIALIZER_OPTION_LINEBREAKS => '',
XML_SERIALIZER_OPTION_RETURN_RESULT => true,
XML_SERIALIZER_OPTION_TYPEHINTS => true,
));
require_once 'XML/Unserializer.php';
$this->xml_unserializer = new XML_Unserializer(array(
XML_UNSERIALIZER_OPTION_COMPLEXTYPE => 'array',
));
}
/**
* Set (or overwrite existing) parameters by passing an array of new parameters.
*
* @access public
* @param array $params Array of parameters (key => val pairs).
*/
public function setParam($params)
{
$app =& App::getInstance();
if (isset($params) && is_array($params)) {
// Merge new parameters with old overriding only those passed.
$this->_params = array_merge($this->_params, $params);
} else {
$app->logMsg(sprintf('Parameters are not an array: %s', $params), LOG_WARNING, __FILE__, __LINE__);
}
}
/**
* Return the value of a parameter, if it exists.
*
* @access public
* @param string $param Which parameter to return.
* @return mixed Configured parameter value.
*/
public function getParam($param)
{
$app =& App::getInstance();
if (array_key_exists($param, $this->_params)) {
return $this->_params[$param];
} else {
$app->logMsg(sprintf('Parameter is not set: %s', $param), LOG_DEBUG, __FILE__, __LINE__);
return null;
}
}
/*
* Load the pedit data and run automatic functions.
*
* @access public
* @author Quinn Comendant
* @since 12 Apr 2006 12:43:47
*/
public function start($initialize_data_file=false)
{
$app =& App::getInstance();
if (!is_dir($this->getParam('data_dir'))) {
trigger_error(sprintf('PEdit data directory not found: %s', $this->getParam('data_dir')), E_USER_WARNING);
}
// The location of the data file. (i.e.: "COMMON_DIR/html/_pedit_data/news/index.xml")
$this->_data_file = sprintf('%s%s.xml', $this->getParam('data_dir'), $_SERVER['SCRIPT_NAME']);
// Make certain the evaluated path matches the assumed path (realpath will expand /../../);
// if realpath returns FALSE we're not concerned because it means the file doesn't exist (_initializeDataFile() will create it).
if (false !== realpath($this->_data_file) && $this->_data_file !== realpath($this->_data_file)) {
$app->logMsg(sprintf('PEdit data file not a real path: %s', $this->_data_file), LOG_CRIT, __FILE__, __LINE__);
trigger_error(sprintf('PEdit data file not a real path: %s', $this->_data_file), E_USER_ERROR);
}
// op is used throughout the script to determine state.
$this->op = getFormData('op');
// Automatic functions based on state.
switch ($this->op) {
case 'Save' :
if ($this->_writeData()) {
$app->dieURL($_SERVER['PHP_SELF']);
}
break;
case 'Restore' :
if ($this->_restoreVersion(getFormData('version'))) {
$app->dieURL($_SERVER['PHP_SELF']);
}
break;
case 'View' :
$this->_data_file = sprintf('%s%s__%s.xml', $this->getParam('data_dir'), $_SERVER['PHP_SELF'], getFormData('version'));
$app->raiseMsg(sprintf(_("This is only a preview of version %s."), getFormData('version')), MSG_NOTICE, __FILE__, __LINE__);
break;
}
// Load data.
$this->_loadDataFile();
if ($initialize_data_file === true) {
$this->_createVersion();
$this->_initializeDataFile();
}
}
/**
* Stores a variable in the pedit data array with the content name, and type of form.
*
* @access public
*
* @param string $content The variable containing the text to store.
* @param array $options Additional options to store with this data.
*/
public function set($name, $options=array())
{
$app =& App::getInstance();
$name = preg_replace('/\s/', '_', $name);
if (!isset($this->_data[$name])) {
$this->_data[$name] = array_merge(array('content' => ''), $options);
} else {
$app->logMsg(sprintf('Duplicate set data: %s', $name), LOG_NOTICE, __FILE__, __LINE__);
}
}
/**
* Returns the contents of a data variable. The variable must first be 'set'.
*
* @access public
* @param string $name The name of the variable to return.
* @return string The trimmed content of the named data.
*/
public function get($name)
{
$name = preg_replace('/\s/', '_', $name);
if ($this->op != 'Edit' && $this->op != 'Versions' && isset($this->_data[$name]['content'])) {
return $this->_data[$name]['content'];
} else {
return '';
}
}
/**
* Prints the beginning
* @since 12 Apr 2006 10:52:35
*/
protected function _fileHash()
{
$app =& App::getInstance();
return md5($app->getParam('signing_key') . $_SERVER['PHP_SELF']);
}
/*
* Load the XML data file into $this->_data.
*
* @access public
* @return bool false on error
* @author Quinn Comendant
* @since 11 Apr 2006 20:36:26
*/
protected function _loadDataFile()
{
$app =& App::getInstance();
if (!file_exists($this->_data_file)) {
if (!$this->_initializeDataFile()) {
$app->logMsg(sprintf('Initializing content file failed: %s', $this->_data_file), LOG_WARNING, __FILE__, __LINE__);
return false;
}
}
$xml_file_contents = file_get_contents($this->_data_file);
$status = $this->xml_unserializer->unserialize($xml_file_contents, false);
if (PEAR::isError($status)) {
$app->logMsg(sprintf('XML_Unserialize error: %s', $status->getMessage()), LOG_WARNING, __FILE__, __LINE__);
return false;
}
$xml_file_data = $this->xml_unserializer->getUnserializedData();
// Only load data specified with set(), even though there may be more in the xml file.
foreach ($this->_data as $name => $initial_data) {
if (isset($xml_file_data[$name])) {
$this->_data[$name] = array_merge($initial_data, $xml_file_data[$name]);
} else {
$this->_data[$name] = $initial_data;
}
}
$this->_data_loaded = true;
return true;
}
/*
* Start a new data file.
*
* @access public
* @return The success value of both xml_serializer->serialize() and _filePutContents()
* @author Quinn Comendant
* @since 11 Apr 2006 20:53:42
*/
protected function _initializeDataFile()
{
$app =& App::getInstance();
$app->logMsg(sprintf('Initializing data file: %s', $this->_data_file), LOG_INFO, __FILE__, __LINE__);
$xml_file_contents = $this->xml_serializer->serialize($this->_data);
return $this->_filePutContents($this->_data_file, $xml_file_contents);
}
/**
* Saves the POSTed data by overwriting the pedit variables in the
* current file.
*
* @access private
* @return bool False if unauthorized or on failure. True on success.
*/
protected function _writeData()
{
$app =& App::getInstance();
if (!$this->_authorized) {
return false;
}
if ($this->_fileHash() != getFormData('file_hash')) {
// Posted data is NOT for this file!
$app->logMsg(sprintf('File_hash does not match current file.', null), LOG_WARNING, __FILE__, __LINE__);
return false;
}
// Scrub incoming data. Escape tags?
$new_data = getFormData('_pedit_data');
if (is_array($new_data) && !empty($new_data)) {
// Make certain a version is created.
$this->_deleteOldVersions();
if (!$this->_createVersion()) {
$app->logMsg(sprintf('Failed creating new version of file.', null), LOG_NOTICE, __FILE__, __LINE__);
return false;
}
// Collect posted data that is already specified in _data (by set()).
foreach ($new_data as $name => $content) {
if (isset($this->_data[$name])) {
$this->_data[$name]['content'] = $content;
}
}
if (is_array($this->_data) && !empty($this->_data)) {
$xml_file_contents = $this->xml_serializer->serialize($this->_data);
return $this->_filePutContents($this->_data_file, $xml_file_contents);
}
}
}
/*
* Writes content to the specified file.
*
* @access public
* @param string $filename Path to file.
* @param string $content Data to write into file.
* @return bool Success or failure.
* @author Quinn Comendant
* @since 11 Apr 2006 22:48:30
*/
protected function _filePutContents($filename, $content)
{
$app =& App::getInstance();
// Ensure requested filename is within the pedit data dir.
if (mb_strpos($filename, $this->getParam('data_dir')) === false) {
$app->logMsg(sprintf('Failed writing file outside pedit data_dir: %s', $filename), LOG_ERR, __FILE__, __LINE__);
return false;
}
// Recursively create directories.
$subdirs = preg_split('!/!', str_replace($this->getParam('data_dir'), '', dirname($filename)), -1, PREG_SPLIT_NO_EMPTY);
// Start with the pedit data_dir base.
$curr_path = $this->getParam('data_dir');
while (!empty($subdirs)) {
$curr_path .= '/' . array_shift($subdirs);
if (!is_dir($curr_path)) {
if (!mkdir($curr_path)) {
$app->logMsg(sprintf('Failed mkdir: %s', $curr_path), LOG_ERR, __FILE__, __LINE__);
return false;
}
}
}
// Open file for writing and truncate to zero length.
if ($fp = fopen($filename, 'w')) {
if (flock($fp, LOCK_EX)) {
fwrite($fp, $content);
flock($fp, LOCK_UN);
} else {
$app->logMsg(sprintf('Could not lock file for writing: %s', $filename), LOG_ERR, __FILE__, __LINE__);
return false;
}
fclose($fp);
// Success!
$app->logMsg(sprintf('Wrote to file: %s', $filename), LOG_DEBUG, __FILE__, __LINE__);
return true;
} else {
$app->logMsg(sprintf('Could not open file for writing: %s', $filename), LOG_ERR, __FILE__, __LINE__);
return false;
}
}
/**
* Makes a copy of the current file with the unix timestamp appended to the
* filename.
*
* @access private
* @return bool False on failure. True on success.
*/
protected function _createVersion()
{
$app =& App::getInstance();
if (!$this->_authorized) {
return false;
}
if ($this->_fileHash() != getFormData('file_hash')) {
// Posted data is NOT for this file!
$app->logMsg(sprintf('File_hash does not match current file.', null), LOG_ERR, __FILE__, __LINE__);
return false;
}
// Ensure current data file exists.
if (!file_exists($this->_data_file)) {
$app->logMsg(sprintf('Data file does not yet exist: %s', $this->_data_file), LOG_NOTICE, __FILE__, __LINE__);
return false;
}
// Do the actual copy. File naming scheme must be consistent!
// filename.php.xml becomes filename.php__1124124128.xml
$version_file = sprintf('%s__%s.xml', preg_replace('/\.xml$/', '', $this->_data_file), time());
if (!copy($this->_data_file, $version_file)) {
$app->logMsg(sprintf('Failed copying new version: %s -> %s', $this->_data_file, $version_file), LOG_ERR, __FILE__, __LINE__);
return false;
}
return true;
}
/*
* Delete all versions older than versions_min_days if there are more than versions_min_qty or 100.
*
* @access public
* @return bool False on failure. True on success.
* @author Quinn Comendant
* @since 12 Apr 2006 11:08:11
*/
protected function _deleteOldVersions()
{
$app =& App::getInstance();
$version_files = $this->_getVersions();
if (is_array($version_files) && sizeof($version_files) > $this->getParam('versions_min_qty')) {
// Pop oldest ones off bottom of array.
$oldest = array_pop($version_files);
// Loop while minimum X qty && minimum X days worth but never more than 100 qty.
while ((sizeof($version_files) > $this->getParam('versions_min_qty')
&& $oldest['unixtime'] < mktime(date('H'), date('i'), date('s'), date('m'), date('d') - $this->getParam('versions_min_days'), date('Y')))
|| sizeof($version_files) > 100) {
$del_file = dirname($this->_data_file) . '/' . $oldest['filename'];
if (!unlink($del_file)) {
$app->logMsg(sprintf('Failed deleting version: %s', $del_file), LOG_ERR, __FILE__, __LINE__);
}
$oldest = array_pop($version_files);
}
}
}
/**
* Returns an array of all archived versions of the current file,
* sorted with newest versions at the top of the array.
*
* @access private
* @return array Array of versions.
*/
protected function _getVersions()
{
$version_files = array();
$dir_handle = opendir(dirname($this->_data_file));
$curr_file_preg_pattern = sprintf('/^%s__(\d+).xml$/', preg_quote(basename($_SERVER['PHP_SELF'])));
while ($dir_handle && ($version_file = readdir($dir_handle)) !== false) {
if (!preg_match('/^\./', $version_file) && !is_dir($version_file) && preg_match($curr_file_preg_pattern, $version_file, $time)) {
$version_files[] = array(
'filename' => $version_file,
'unixtime' => $time[1],
'filesize' => filesize(dirname($this->_data_file) . '/' . $version_file)
);
}
}
if (is_array($version_files) && !empty($version_files)) {
array_multisort($version_files, SORT_DESC);
return $version_files;
} else {
return array();
}
}
/**
* Makes a version backup of the current file, then copies the specified
* archived version over the current file.
*
* @access private
* @param string $version Unix timestamp of archived version to restore.
* @return bool False on failure. True on success.
*/
protected function _restoreVersion($version)
{
$app =& App::getInstance();
if (!$this->_authorized) {
return false;
}
// The file to restore.
$version_file = sprintf('%s__%s.xml', preg_replace('/\.xml$/', '', $this->_data_file), $version);
// Ensure specified version exists.
if (!file_exists($version_file)) {
$app->logMsg(sprintf('Cannot restore non-existent file: %s', $version_file), LOG_NOTICE, __FILE__, __LINE__);
return false;
}
// Make certain a version is created.
if (!$this->_createVersion()) {
$app->logMsg(sprintf('Failed creating new version of file.', null), LOG_ERR, __FILE__, __LINE__);
return false;
}
// Do the actual copy.
if (!copy($version_file, $this->_data_file)) {
$app->logMsg(sprintf('Failed copying old version: %s -> %s', $version_file, $this->_data_file), LOG_ERR, __FILE__, __LINE__);
return false;
}
// Success!
$app->raiseMsg(sprintf(_("Page has been restored to version %s."), $version), MSG_SUCCESS, __FILE__, __LINE__);
return true;
}
} // End class.