<?php
/*
 * $RCSfile: GalleryUtilities.class,v $
 *
 * Gallery - a web based photo album viewer and editor
 * Copyright (C) 2000-2005 Bharat Mediratta
 *
 * This program 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 2 of the License, or (at
 * your option) any later version.
 *
 * This program 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 this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street - Fifth Floor, Boston, MA  02110-1301, USA.
 */
/**
 * @version $Revision: 1.148 $ $Date: 2005/09/04 20:05:30 $
 * @package GalleryCore
 * @author Bharat Mediratta <bharat@menalto.com>
 */

/**
 * The prefix for all HTTP GET/POST arguments
 *
 */
define('GALLERY_FORM_VARIABLE_PREFIX', 'g2_');

/**
 * A collection of useful utilities that have no obvious home
 *
 * All of these utilities should be accessed in a static sense,
 * ie:
 *
 *   GalleryUtilities::getFileExtension($filename);
 *
 * Try not to jam too many methods into this class.  Only put methods here if
 * they are of obvious value to the class layer and there's no other home for
 * them.
 *
 * @package GalleryCore
 * @subpackage Classes
 * @static
 */
class GalleryUtilities {

    /*
     * ****************************************
     *                 Methods
     * ****************************************
     */

    /**
     * Get the type of the file from its filename
     *
     * Eg, "foo.jpg" yields 'foo', 'jpg'
     *     "foo.bar.jpeg" yields 'foo.bar', 'jpeg'
     *
     * @param string the filename
     * @return array the file base name, the file extension
     * @static
     */
    function getFileNameComponents($filename) {

	$pos = strrpos($filename, '.');

	/* No dot == it's all base, no extension */
	if ($pos === false) {
	    return array($filename, '');
	}

	$pos++;

	/* If it's the last char in the name, just return the base */
	if ($pos >= strlen($filename)) {
	    return array(substr($filename, 0, $pos-1), '');
	}

	return array(substr($filename, 0, $pos-1), substr($filename, $pos));
    }

    /**
     * Return the file's extension
     *
     * Eg, "foo.jpg" yields "jpg"
     *
     * @param string the filename
     * @return array the file extension
     * @static
     */
    function getFileExtension($filename) {
	list ($base, $extension) = GalleryUtilities::getFileNameComponents($filename);
	return $extension;
    }

    /**
     * Return the file's extension
     *
     * Eg, "foo.jpg" yields "foo"
     *
     * @param string the filename
     * @return array the file base
     * @static
     */
    function getFileBase($filename) {
	list ($base, $extension) = GalleryUtilities::getFileNameComponents($filename);
	return $base;
    }

    /**
     * Return data about file attached to request
     *
     * @param string a key
     * @param boolean (optional) false to omit gallery variable prefix (not recommended)
     * @return array file data
     * @static
     */
    function getFile($key, $prefix=true) {
	$file = array();
	if ($prefix) {
	    $key = GALLERY_FORM_VARIABLE_PREFIX . $key;
	}
	if (isset($_FILES[$key])) {
	    /*
	     * Later on during our sanitization process we're going to call
	     * stripslashes on our file name.  But it may legitimately have
	     * backslashes in it (eg c:\apache\tmp\php195.jpg), so make sure those
	     * are escaped at this time.  There's gotta be a better way to
	     * handle this.
	     */
	    $file = $_FILES[$key];
	    if (get_magic_quotes_gpc()) {
		$file['tmp_name'] = addslashes($file['tmp_name']);
	    }

	    /* Perform any necessary transformations on our values */
	    GalleryUtilities::sanitizeInputValues($file);
	}

	return $file;
    }

    /**
     * Return all request variables that match the prefix
     *
     * @param string a key
     * @param boolean (optional) false to omit gallery variable prefix (not recommended)
     * @return array key value pairs
     * @static
     */
    function getFormVariables($key, $prefix=true) {
	if ($prefix) {
	    $key = GALLERY_FORM_VARIABLE_PREFIX . $key;
	}
	$form = array();
	if (isset($_POST[$key])) {
	    $form = $_POST[$key];
	}

	if (isset($_FILES[$key])) {
	    /*
	     * Later on during our sanitization process we're going to call
	     * stripslashes on our file name.  But it may legitimately have
	     * backslashes in it (eg c:\apache\tmp\php195.jpg), so make sure those
	     * are escaped at this time.  There's gotta be a better way to
	     * handle this.
	     */
	    $postForm = $_FILES[$key];
	    if (get_magic_quotes_gpc()) {
		for ($i = 1; $i <= sizeof($postForm['tmp_name']); $i++) {
		    $postForm['tmp_name'][$i] = addslashes($postForm['tmp_name'][$i]);
		}
	    }
	    $form = GalleryUtilities::array_merge_replace($form, $postForm);
	}

	if (isset($_GET[$key])) {
	    $form = GalleryUtilities::array_merge_replace($form, $_GET[$key]);
	}

	/* Perform any necessary transformations on our values */
	GalleryUtilities::sanitizeInputValues($form);

	return $form;
    }

    /**
     * Return all request variables from the URL except the listed keys
     *
     * @param array keys to skip
     * @return array key value pairs
     * @static
     */
    function getUrlVariablesFiltered($skip = null) {
	$filter = array();
	foreach ($skip as $key) {
	    $filter[] = GALLERY_FORM_VARIABLE_PREFIX . $key;
	}

	$vars = array();
	foreach ($_GET as $key => $value) {
	    if (in_array($key, $filter)) {
		continue;
	    }
	    $vars[$key] = $value;
	}

	return $vars;
    }

    /**
     * Merges two arrays and replace existing Entrys
     *
     * Merges two array like the PHP function array_merge_recursive.
     * The main difference is that existing keys will be replaced with new values,
     * not combined in a new sub array.
     *
     * Usage:
     *        $newArray = array_merge_replace( $array, $newValues );
     *
     * @access public
     * @author Tobias Tom <t.tom@succont.de>
     * @param array $array first array with 'replaceable' values
     * @param array $newValues array which will be merged into first one
     * @return array resulting array
     */
    function array_merge_replace($array, $newValues) {
	foreach ($newValues as $key => $value) {
	    if (is_array($value)) {
		if (!isset($array[$key])) {
		    $array[$key] = array();
		}
		$array[$key] = GalleryUtilities::array_merge_replace($array[$key], $value);
	    } else {
		if (isset($array[$key]) && is_array($array[$key])) {
		    $array[$key][0] = $value;
		} else {
		    if (isset($array) && !is_array($array)) {
			$temp = $array;
			$array = array();
			$array[0] = $temp;
		    }
		    $array[$key] = $value;
		}
	    }
	}
	return $array;
    }


    /**
     * Remove all request variables that match the prefix
     *
     * @param string a prefix
     * @param boolean (optional) false to omit gallery variable prefix (not recommended)
     * @static
     */
    function removeFormVariables($key, $prefix=true) {
	/* Remove all matching GET and POST variables */
	if ($prefix) {
	    $key = GALLERY_FORM_VARIABLE_PREFIX . $key;
	}
	unset($_POST[$key]);
	unset($_FILES[$key]);
	unset($_GET[$key]);
    }


    /**
     * Return the specified request variables
     *
     * Accept any number of keys and return that number of values, in order.
     *
     * @param mixed a single key or many keys
     * @return mixed a single value or many values
     * @static
     */
    function getRequestVariables() {
	$values = array();
	foreach (func_get_args() as $argName) {
	    $values[] = GalleryUtilities::_getRequestVariable(
		GALLERY_FORM_VARIABLE_PREFIX . $argName);
	}

	/* Sanitize the input */
	GalleryUtilities::sanitizeInputValues($values);

	if (sizeof($values) == 1) {
	    return $values[0];
	} else {
	    return $values;
	}
    }

    /**
     * Return the specified request variables (omit gallery variable prefix).
     * Should be used only when interacting with an external api where prefix can't be used.
     *
     * Accept any number of keys and return that number of values, in order.
     *
     * @param mixed a single key or many keys
     * @return mixed a single value or many values
     * @static
     */
    function getRequestVariablesNoPrefix() {
	$values = array();
	foreach (func_get_args() as $argName) {
	    $values[] = GalleryUtilities::_getRequestVariable($argName);
	}

	/* Sanitize the input */
	GalleryUtilities::sanitizeInputValues($values);

	if (sizeof($values) == 1) {
	    return $values[0];
	} else {
	    return $values;
	}
    }

    /**
     * Push the given key => value pair back into the request
     *
     * @param string the key
     * @param string the value
     * @param boolean (optional) false to omit gallery variable prefix (not recommended)
     * @static
     */
    function putRequestVariable($key, $value, $prefix=true) {
	if ($prefix) {
	    $key = GALLERY_FORM_VARIABLE_PREFIX . $key;
	}

	/* Simulate the damage caused by magic_quotes */
	GalleryUtilities::unsanitizeInputValues($key);
	GalleryUtilities::unsanitizeInputValues($value);

	$keyPath = preg_split('/[\[\]]/', $key, -1, PREG_SPLIT_NO_EMPTY);
	GalleryUtilities::_internalPutRequestVariable($keyPath, $value, $_GET);
    }

    /**
     * Take a path in the form of ('foo', 'bar', 'baz') and a destination array
     * and put the value into it like this:
     *
     *   $destination['foo']['bar']['baz'] = $value;
     *
     * @param array the key path
     * @param mixed the value
     * @param array the destination
     * @access private
     * @static
     */
    function _internalPutRequestVariable($keyPath, $value, &$destination) {
	$key = array_shift($keyPath);
	if (empty($keyPath)) {
	    $destination[$key] = $value;
	} else {
	    GalleryUtilities::_internalPutRequestVariable($keyPath, $value, $destination[$key]);
	}
    }

    /**
     * Check to see if the given key is in the request
     *
     * @param string the key
     * @param string the value
     * @param boolean (optional) false to omit gallery variable prefix (not recommended)
     * @static
     */
    function hasRequestVariable($key, $prefix=true) {
	if ($prefix) {
	    $key = GALLERY_FORM_VARIABLE_PREFIX . $key;
	}
	$value = GalleryUtilities::_getRequestVariable($key);
	return !empty($value);
    }

    /**
     * Remove a request variable
     *
     * @param string the key
     * @param boolean (optional) false to omit gallery variable prefix (not recommended)
     * @static
     */
    function removeRequestVariable($key, $prefix=true) {
	if ($prefix) {
	    $key = GALLERY_FORM_VARIABLE_PREFIX . $key;
	}
	$keyPath = preg_split('/[\[\]]/', $key, -1, PREG_SPLIT_NO_EMPTY);
	GalleryUtilities::_internalRemoveRequestVariable($keyPath, $_GET);
	GalleryUtilities::_internalRemoveRequestVariable($keyPath, $_POST);
    }

    /**
     * Take a path in the form of ('foo', 'bar', 'baz') and a source array
     * and remove the value from it like this:
     *
     *   unset($source['foo']['bar']['baz']);
     *
     * @param array the key path
     * @param array the source
     * @access private
     * @static
     */
    function _internalRemoveRequestVariable($keyPath, &$array) {
	$key = array_shift($keyPath);
	if (empty($keyPath)) {
	    unset($array[$key]);
	} else {
	    if (isset($array[$key])) {
		GalleryUtilities::_internalRemoveRequestVariable($keyPath, $array[$key]);
	    }
	}
    }

    /**
     * Return variable name with prepended prefix
     *
     * @param string key
     * @return string key with prefix
     * @static
     */
    function prefixFormVariable($key) {
	return GALLERY_FORM_VARIABLE_PREFIX . $key;
    }

    /**
     * Return a string of ? markers
     *
     * @param int the number of markers to return
     * @access private
     * @static
     */
    function makeMarkers($count, $markerFragment='?') {
	if (is_array($count)) {
	    $count = sizeof($count);
	}

	$markers = '';
	if ($count > 1) {
	    $markers = str_repeat($markerFragment . ',', $count-1);
	}
	if ($count != 0) {
	    $markers .= $markerFragment;
	}

	return $markers;
    }

    /**
     * Convert a filesystem path inside the gallery directory to an absolute URL
     *
     *  ie /path/to/gallery/themes/classic/styles/style.css =>
     *     http://example.com/gallery/themes/classic/styles/style.css
     *
     * @param string path to a file in the gallery directory tree
     * @return string a url
     * @static
     */
    function convertPathToUrl($path) {
	global $gallery;
	$platform = $gallery->getPlatform();
	$dirbase = $platform->realpath(dirname(__FILE__) . '/../../..') . '/';

	/*
	 * Factor the gallery code base out of the path, accounting for
	 * differences in directory separators between platforms.
	 */
	$slash = $platform->getDirectorySeparator();
	if ($slash != '/') {
	    $dirbase = str_replace($slash, '/', $dirbase);
	    $path = str_replace($slash, '/', $path);
	}
	$relativePath = str_replace($dirbase, '', $path);

	/* Prepend the G2 base URL */
	$urlGenerator =& $gallery->getUrlGenerator();
	return $urlGenerator->generateUrl(array('href' => $relativePath));
    }

    /**
     * Scale the given width/height to a new target size, maintaining
     * aspect ratio, but only if the dimensions are already larger than the
     * target (in other words, don't increase the dimensions)
     *
     * @param int width
     * @param int height
     * @param int target width
     * @param int (optional) target height, defaults to same as width
     * @return array(width, height)
     * @static
     */
    function shrinkDimensionsToFit($width, $height, $targetWidth, $targetHeight=null) {
	if (!isset($targetHeight)) {
	    $targetHeight = $targetWidth;
	}
	if ($width > $targetWidth || $height > $targetHeight) {
	    list ($width, $height) = GalleryUtilities::scaleDimensionsToFit(
					    $width, $height, $targetWidth, $targetHeight);
	}
	return array($width, $height);
    }

    /**
     * Scale the given width/height to a new target size, maintaining
     * aspect ratio
     *
     * @param int width
     * @param int height
     * @param int target width
     * @param int (optional) target height, defaults to same as width
     * @return array(width, height)
     * @static
     */
    function scaleDimensionsToFit($width, $height, $targetWidth, $targetHeight=null) {
	if (!isset($targetHeight)) {
	    $targetHeight = $targetWidth;
	}
	$aspect = $height / $width;
	if ($aspect < $targetHeight / $targetWidth) {
	    $width = (int)$targetWidth;
	    $height = (int)round($targetWidth * $aspect);
	} else {
	    $width = (int)round($targetHeight / $aspect);
	    $height = (int)$targetHeight;
	}
	return array($width, $height);
    }

    /**
     * Round a float and convert to a string.
     * Replace , with . in case current locale uses comma as fraction separator.
     *
     * @param float value to round
     * @param int precision, defaults to zero
     * @return string rounded value
     * @static
     */
    function roundToString($floatValue, $precision=0) {
	return str_replace(',', '.', round($floatValue, $precision));
    }

    /**
     * Cast to float taking into account that older php versions will not treat "."
     * as a decimal separator if the current locale uses "," - when we stop supporting
     * these older versions we can ditch this method and just cast to (float).
     * (Note that newer php versions may accept only "." even if locale uses ",")
     */
    function castToFloat($value) {
	if (is_string($value) && (float)'1.1' != 1.1
		&& ($test = (string)1.1) != '1.1' && strlen($test) == 3) {
	    return (float)str_replace('.', $test{1}, $value);
	}
	return (float)$value;
    }

    /**
     * Figure out if the object specified is an instance of or an instance of a
     * sub class of the class specified.
     *
     * @param object any kind of object
     * @param string a class name
     * @return boolean true or false
     * @static
     */
    function isA($instance, $className) {
	if (function_exists('is_a')) {
	    return is_a($instance, $className);
	} else {
	    return (is_subclass_of($instance, $className) ||
		    !strcasecmp(get_class($instance), $className));
	}
    }

    /**
     * Figure out if the object specified is an instance of the class specified,
     * excluding subclasses
     *
     * @param object any kind of object
     * @param string a class name
     * @return boolean true or false
     * @static
     */
    function isExactlyA($instance, $className) {
	return (!strcasecmp(get_class($instance), $className));
    }

    /**
     * An entity-safe equivalent to substr (http://php.net/substr)
     *
     * @param string the input string
     * @param integer the 0 based start index (negative values mean subtract
     *                from the end)
     * @param integer the desired length.  If negative negative then that many characters will
     *                be omitted from the end of string (after the start position has been
     *                calculated when a start is negative)
     * @param boolean true if the final length be a count of entities, instead
     *                of characters. (default: true)
     * @return array int the number of entities in the string
     *               string the output string
     *
     */
    function entitySubstr($string, $start, $length=null, $countEntitiesAsOne=true) {
	$stringLength = strlen($string);
	if ($stringLength < $start) {
	    return array(0, false);
	}

	if (!isset($length)) {
	    $length = $stringLength;
	}

	if (!$countEntitiesAsOne && $start == 0 && $length >= $stringLength) {
	    return array(strlen($string), $string);
	}

	if (preg_match_all('(&#x[A-Fa-f0-9]+;|&#[0-9]+;|&[A-Za-z0-9]+;|.|\n)', $string, $reg)) {
	    $charArray = $reg[0];
	    $charArrayLength = count($charArray);

	    /* if $length < 0, then it's really the end index */
	    $cookedStart = ($start < 0) ? $charArrayLength + $start : $start;
	    $cookedLength = ($length < 0) ? $charArrayLength - $cookedStart + $length : $length;

	    /* We now have the proper begin/end indices, so grab that slice */
	    if ($countEntitiesAsOne) {
		$slice = array_slice($charArray, $cookedStart, $cookedLength);
		return array(count($slice), join('', $slice));
	    } else {
		$cookedText = '';
		$actualLength = 0;
		for ($i = $cookedStart; $i < $cookedLength; $i++) {
		    if ($charArray[$i][0] == '&') {
			$size = strlen($charArray[$i]);
		    } else {
			$size = 1;
		    }

		    if ($actualLength + $size > $cookedLength) {
			/* We're done */
			break;
		    }
		    $cookedText .= $charArray[$i];
		    $actualLength += $size;
		}
		return array($actualLength, $cookedText);
	    }
	} else {
	    /* How could we get here?  Our regex should match everything */
	    $newString = substr($string, $start, $length);
	    return array(strlen($newString), $newString);
	}
    }


    /**
     * Takes a string of utf-8 encoded characters and converts it to a string
     * of unicode entities.  Each unicode entity has the form &#nnnnn; n={0..9}
     * and can be displayed by utf-8 supporting browsers.
     *
     * This function was posted in a comment here:
     *   http://www.php.net/manual/en/function.utf8-decode.php
     * by "ronen at greyzone dot com".
     *
     * @param $source string encoded using utf-8 [STRING]
     * @return string of unicode entities [STRING]
     * @access public
     * @static
     */
    function utf8ToUnicodeEntities($source) {
	/*
	 * array used to figure what number to decrement from character order
	 * value according to number of characters used to map unicode to ascii by
	 * utf-8
	 */
	$decrement[4] = 240;
	$decrement[3] = 224;
	$decrement[2] = 192;
	$decrement[1] = 0;

	/* the number of bits to shift each charNum by */
	$shift[1][0] = 0;
	$shift[2][0] = 6;
	$shift[2][1] = 0;
	$shift[3][0] = 12;
	$shift[3][1] = 6;
	$shift[3][2] = 0;
	$shift[4][0] = 18;
	$shift[4][1] = 12;
	$shift[4][2] = 6;
	$shift[4][3] = 0;

	$pos = 0;
	$len = strlen($source);
	$encodedString = '';
	while ($pos < $len) {
	    $asciiPos = ord(substr($source, $pos, 1));
	    if (($asciiPos >= 240) && ($asciiPos <= 255)) {
		/* 4 chars representing one unicode character */
		$thisLetter = substr($source, $pos, 4);
		$pos += 4;
	    }
	    else if (($asciiPos >= 224) && ($asciiPos <= 239)) {
		/* 3 chars representing one unicode character */
		$thisLetter = substr($source, $pos, 3);
		$pos += 3;
	    }
	    else if (($asciiPos >= 192) && ($asciiPos <= 223)) {
		/* 2 chars representing one unicode character */
		$thisLetter = substr($source, $pos, 2);
		$pos += 2;
	    }
	    else {
		/* 1 char (lower ascii) */
		$thisLetter = substr($source, $pos, 1);
		$pos += 1;
	    }

	    /* process the string representing the letter to a unicode entity */
	    $thisLen = strlen ($thisLetter);
	    $thisPos = 0;
	    $decimalCode = 0;
	    while ($thisPos < $thisLen) {
		$thisCharOrd = ord(substr($thisLetter, $thisPos, 1));
		if ($thisPos == 0) {
		    $charNum = intval($thisCharOrd - $decrement[$thisLen]);
		    $decimalCode += ($charNum << $shift[$thisLen][$thisPos]);
		} else {
		    $charNum = intval($thisCharOrd - 128);
		    $decimalCode += ($charNum << $shift[$thisLen][$thisPos]);
		}
		$thisPos++;
	    }
	    if (($thisLen == 1) && ($decimalCode<=128)) {
		$encodedLetter = $thisLetter;
	    } else {
		$encodedLetter = '&#' . $decimalCode . ';';
	    }
	    $encodedString .= $encodedLetter;
	}
	return $encodedString;
    }

    /**
     * Perform necessary pre-processing on the "value" part of the incoming
     * array (which may be an associative array or a simple list of values).
     * We do the following:
     * 1.  Convert UTF-8 values to Unicode entities
     * 2.  Sanitize any input values to remove dangerous values
     */
    function sanitizeInputValues(&$value, $adaptForMagicQuotes=true) {
	if (is_array($value)) {
	    foreach (array_keys($value) as $key) {
		$newKey = $key;
		GalleryUtilities::sanitizeInputValues($newKey);
		if ($key != $newKey) {
		    $value[$newKey] =& $value[$key];
		    unset($value[$key]);
		}

		GalleryUtilities::sanitizeInputValues($value[$newKey]);
	    }
	} else {
	    /*
	     * Simulate calling htmlspecialchars($value, ENT_COMPAT, 'UTF-8')
	     * We avoid using htmlspecialchars directly because on some versions
	     * of PHP (notable PHP 4.1.2) it changes the character set of the input
	     * data (in one environment it converted the UTF-8 data to ISO-8859-1).
	     */
	    $value = str_replace(array('&', '"', '<', '>'),
				 array('&amp;', '&quot;', '&lt;', '&gt;'),
				 $value);

	    /* Undo the damage caused by magic_quotes */
	    if ($adaptForMagicQuotes) {
		if (get_magic_quotes_gpc()) {
		    $value = stripslashes($value);
		}
	    }
	}
    }

    /**
     * Undo the preprocessing done in sanitizeInputValues (useful when we
     * put values back into the request)
     */
    function unsanitizeInputValues(&$value, $adaptForMagicQuotes=true) {
	if (is_array($value)) {
	    foreach (array_keys($value) as $key) {
		GalleryUtilities::unsanitizeInputValues($value[$key]);
	    }
	} else {
	    /* Unsanitize dangerous html entities */
	    $value = GalleryUtilities::htmlEntityDecode($value);

	    /* Redo the damage caused by magic_quotes */
	    if ($adaptForMagicQuotes) {
		if (get_magic_quotes_gpc()) {
		    $value = addslashes($value);
		}
	    }
	}
    }

    /**
     * Unescape embedded UTF-8 entities in the given string
     *
     * @param string the input string with UTF-8 entities
     * @return string the UTF-8 string
     */
    function unicodeEntitiesToUtf8($string) {
	$string = preg_replace('/&#([xa-f\d]+);/mei',
	    "GalleryUtilities::unicodeValueToUtf8Value('\\1')", $string);
	return $string;
    }

    /**
     * A multibyte safe version of substr() for UTF8.
     *
     * @param string the input string containing raw UTF-8
     * @return string a multibyte safe substring of input value
     */
    function utf8Substring($string, $start, $length) {
	static $hasMbSubstr;
	if (!isset($hasMbSubstr)) {
	    $hasMbSubstr = function_exists('mb_substr');
	}

	if ($hasMbSubstr) {
	    return mb_substr($string, $start, $length, 'UTF-8');
	} else {
	    return GalleryUtilities::_phpUtf8Substring($string, $start, $length);
	}
    }

    /**
     * A multibyte safe version of substr() for UTF8.
     *
     * Convert UTF8 to Unicode entities, run our entity safe substr() routine
     * on that, then convert it back to unicode when we're done.
     *
     * This is going to be a whole lot slower than mb_substr, so we should use
     * that instead, whenever we can.
     * Note: this function treats all html entities like &amp; as single characters,
     *       but mb_substr would treat this as 5 characters.
     *
     * @param string the input string containing raw UTF-8
     * @return string a multibyte safe substring of input value
     * @access private
     */
    function _phpUtf8Substring($string, $start, $length) {
	$string = GalleryUtilities::utf8ToUnicodeEntities($string);
	list ($len, $string) = GalleryUtilities::entitySubstr($string, $start, $length);
	$string = GalleryUtilities::unicodeEntitiesToUtf8($string);
	return $string;
    }

    /**
     * Convert a numerical unicode value to a multibyte UTF-8 string
     * Adapted from code found here: http://us2.php.net/utf8_encode
     *
     * @param num the unicode value
     * @return string the UTF-8 string
     */
    function unicodeValueToUtf8Value($num){
	if ($num[0] == 'x') {
	    /* Convert hex to decimal */
	    $num = hexdec(substr($num, 1));
	}

	if ($num < 128) {
	    return chr($num);
	}
	if ($num < 2048) {
	    return (chr(192 + ($num >> 6)) .
		    chr(128 + ($num & 63)));
	}
	if ($num < 65535) {
	    return (chr(224 + ($num >> 12)) .
		    chr(128 + (($num >> 6 ) & 63)) .
		    chr(128 + ($num & 63)));
	}
	if ($num < 2097152) {
	    return (chr(240 + ($num >> 18)) .
		    chr(128 + (($num >> 12) & 63)) .
		    chr(128 + (($num >> 6) & 63)) .
		    chr(128 + ($num & 63)));
	}
	return '';
    }

    /**
     * Equivalent to html_entity_decode() for PHP <4.3.0 which doesn't have it.
     *
     * @param string with html entities
     * @return same string without them
     */
    function htmlEntityDecode($string) {
	if (function_exists("html_entity_decode")) {
	    return html_entity_decode($string, ENT_COMPAT);
	}

	static $translation_table = null;
	if (!isset($translation_table)) {
	    $translation_table = get_html_translation_table(HTML_ENTITIES, ENT_COMPAT);
	    $translation_table = array_flip($translation_table);
	}

	return strtr($string, $translation_table);
    }

    /**
     * Return a specified request variable from the GET or POST vars.
     *
     * @param string a single key
     * @return string a single value
     * @static
     */
    function _getRequestVariable($key) {
	$keyPath = preg_split('/[\[\]]/', $key, -1, PREG_SPLIT_NO_EMPTY);
	$result = GalleryUtilities::_internalGetRequestVariable($keyPath, $_GET);
	if (isset($result)) {
	    return $result;
	}
	return GalleryUtilities::_internalGetRequestVariable($keyPath, $_POST);
    }

    /**
     * Take a path in the form of ('foo', 'bar', 'baz') and a source array
     * and get the value from it like this:
     *
     *   return $source['foo']['bar']['baz'];
     *
     * @param array the key path
     * @param array the source
     * @return the value or null if it does not exist
     * @access private
     * @static
     */
    function _internalGetRequestVariable($keyPath, &$array) {
	$key = array_shift($keyPath);
	if (empty($keyPath)) {
	    if (isset($array[$key])) {
		return $array[$key];
	    } else {
		return null;
	    }
	} else {
	    return GalleryUtilities::_internalGetRequestVariable($keyPath, $array[$key]);
	}
    }

    /**
     * Return true if the path exists and is in the given path list
     *
     * Make sure to pass paths in the system charset to this method
     *
     * @param string the path
     * @param string the list of legal paths
     * @return true or false
     */
    function isPathInList($path, $list) {
	global $gallery;
	$platform = $gallery->getPlatform();
	$slash = $platform->getDirectorySeparator();
	$path = $platform->realpath($path) . $slash;
	$compare = GalleryUtilities::isA($platform, 'WinNtPlatform') ? 'strncasecmp' : 'strncmp';

	foreach ($list as $element) {
	    if (($element = $platform->realpath($element)) === false) {
		continue;
	    }
	    /*
	     * Make sure the compare directory has a trailing slash so that
	     * /tmp doesn't accidentally match /tmpfoo
	     */
	    if ($element{strlen($element)-1} != $slash) {
		$element .= $slash;
	    }

	    if (!$compare($element, $path, strlen($element))) {
		return true;
	    }
	}
	return false;
    }

    /**
     * Return the address of the remote host.
     *
     * @return string the remote host address (or null)
     */
    function getRemoteHostAddress() {
	if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
	    $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
	} else if (isset($_SERVER['HTTP_CLIENT_IP'])) {
	    $ip = $_SERVER['HTTP_CLIENT_IP'];
	} else if (isset($_SERVER['REMOTE_ADDR'])) {
	    $ip = $_SERVER['REMOTE_ADDR'];
	} else {
	    return null;
	}
	return $ip;
    }

    /**
     * Make sure that the given directory exists (creating it and parent directories if necessary)
     *
     * @param string the dir
     * @return array boolean true if dir exists or was created successfully
     *               array of directories that were created
     */
    function guaranteeDirExists($dir) {
	global $gallery;
	$platform = $gallery->getPlatform();
	if ($platform->file_exists($dir)) {
	    return array($platform->is_dir($dir), array());
	}

	static $cacheKey = 'GalleryUtilities::guaranteeDirExists';
	if (GalleryDataCache::containsKey($cacheKey)) {
	    $dirPerms = GalleryDataCache::get($cacheKey);
	} else {
	    /* To avoid looping if getPluginParameter calls guaranteeDirExists */
	    GalleryDataCache::put($cacheKey, 0);
	    list ($ret, $dirPerms) =
		GalleryCoreApi::getPluginParameter('module', 'core', 'permissions.directory');
	    /* Ignore error here.. then recheck $dir in case it was created in nested call */
	    GalleryDataCache::put($cacheKey, $dirPerms);
	    if ($platform->file_exists($dir)) {
		return array($platform->is_dir($dir), array());
	    }
	}

	list ($success, $created) = GalleryUtilities::guaranteeDirExists(dirname($dir));
	if ($success) {
	    $success = !empty($dirPerms) ? $platform->mkdir($dir, $dirPerms)
					 : $platform->mkdir($dir);
	    if ($success) {
		$created[] = $dir;
	    }
	}
	return array($success, $created);
    }

    /**
     * Turn a set of albums into a depth tree suitable for display in a
     * hierarchical format.
     *
     * @param array the GalleryAlbumItem instances
     * @return array An associative array of tree data.  Each node has a 'depth' element, and
     *               a 'data' element that contains all the members of the current album item.
     *
     * @access private
     */
    function createAlbumTree($albums) {
	if (empty($albums)) {
	    $tree = array(); return $tree;  /* Help CodeAudit match up returns */
	}

	/* Index the albums by id */
	$map = array();
	foreach ($albums as $album) {
	    $albumId = $album->getId();
	    $parentId = $album->getParentId();
	    $map[$albumId]['instance'] = $album;
	    if (!empty($parentId)) {
		$map[$albumId]['parent'] = $parentId;
		$map[$parentId]['children'][] = $albumId;
	    }
	}

	/*
	 * Prune parents that don't exist.  This can occur if we have multiple
	 * roots (unusual) or an album in the middle of the hierarchy that is
	 * not viewable.
	 */
	foreach ($map as $id => $info) {
	    if (isset($info['parent']) && !isset($map[$info['parent']]['instance'])) {
		unset($map[$info['parent']]);
	    }
	}

	/* Find root albums */
	foreach ($map as $id => $info) {
	    if (!isset($info['parent']) || !isset($map[$info['parent']])) {
		$roots[] = $id;
	    }
	}

	/* Walk the root albums */
	$tree = array();
	foreach ($roots as $id) {
	    $tree = array_merge($tree, GalleryUtilities::_createDepthTree($map, $id));
	}

	return $tree;
    }

    /**
     * Recursively walk a parent/child map and build the depth tree.
     *
     * @access private
     */
    function _createDepthTree(&$map, $id, $depth=0) {
	$data = array();
	$data[] = array('depth' => $depth, 'data' => $map[$id]['instance']->getMemberData());
	if (isset($map[$id]['children'])) {
	    foreach ($map[$id]['children'] as $childId) {
		$data = array_merge($data,
				    GalleryUtilities::_createDepthTree($map, $childId, $depth+1));
	    }
	}

	return $data;
    }

    /**
     * Return an approximate filename of the given GalleryEntity, or "unknown"
     * if we can't figure it out
     *
     * @param object entity a GalleryEntity
     * @return array object GalleryStatus a status code
     *               string pseudoFileName a filename
     */
    function getPseudoFileName($entity) {
	/*
	 * If our GalleryEntity is a GalleryFileSystemEntity, then we've got a
	 * path component so we're cool.  If it's a derivative, then get the
	 * pseudo filename of its parent and use that instead (but make sure
	 * the extension matches derivative, as parent mime type may differ).
	 * If it's neither, then return 'unknown' for now.
	 */
	if (GalleryUtilities::isA($entity, 'GalleryFileSystemEntity')) {
	    $pseudoFileName = $entity->getPathComponent();
	} else if (GalleryUtilities::isA($entity, 'GalleryDerivative')) {
	    list ($ret, $parentEntity) = GalleryCoreApi::loadEntitiesById($entity->getParentId());
	    if ($ret->isError()) {
		return array($ret->wrap(__FILE__, __LINE__), null);
	    }

	    if (GalleryUtilities::isA($parentEntity, 'GalleryFileSystemEntity')) {
		$pseudoFileName = $parentEntity->getPathComponent();
		if (!method_exists($parentEntity, 'getMimeType') ||
			$parentEntity->getMimeType() != $entity->getMimeType()) {
		    list ($ret, $extensions) =
			GalleryCoreApi::convertMimeToExtensions($entity->getMimeType());
		    if ($ret->isError()) {
			return array($ret->wrap(__FILE__, __LINE__), null);
		    }
		    if (!empty($extensions)) {
			if (method_exists($parentEntity, 'getMimeType')) {
			    /* Change extension for mime type of this derivative */
			    $pseudoFileName =
				preg_replace('{\.[^.]+$}', '.' . $extensions[0], $pseudoFileName);
			} else {
			    /* Non-item parent, like an album.. add extension for this mime type */
			    $pseudoFileName .= '.' . $extensions[0];
			}
		    }
		}
	    }
	}
	return array(GalleryStatus::success(),
		     isset($pseudoFileName) ? $pseudoFileName : 'unknown');
    }

    /**
     * Return a date and time string that is conformant to RFC 2616
     *
     * @param integer the unix timestamp of the date we want to return,
     *                empty if we want the current time
     * @return string a date-string conformant to the RFC 2616
     *
     * @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3
     */
    function getHttpDate($time = '') {
	if ($time == '') {
	    $time = time();
	}
	/* Use fixed list of weekdays and months, so we don't have to
	 * fiddle with locale stuff
	 */
	$months = array('01' => 'Jan', '02' => 'Feb', '03' => 'Mar',
			'04' => 'Apr', '05' => 'May', '06' => 'Jun',
			'07' => 'Jul', '08' => 'Aug', '09' => 'Sep',
			'10' => 'Oct', '11' => 'Nov', '12' => 'Dec');
	$weekdays = array('1' => 'Mon', '2' => 'Tue', '3' => 'Wed',
			  '4' => 'Thu', '5' => 'Fri', '6' => 'Sat',
			  '0' => 'Sun');
	$dow = $weekdays[gmstrftime('%w', $time)];
	$month = $months[gmstrftime('%m', $time)];
	$out = gmstrftime('%%s, %d %%s %Y %H:%M:%S GMT', $time);
	return sprintf($out, $dow, $month);
    }

    /**
     * Get contents of MANIFEST files
     *
     * @return array (file => array('checksum'=>..,'size'=>..,'viewable'=>..), ..)
     */
    function readManifest() {
	/*
	 * Be careful not to reference $gallery here; this method is called from the installer
	 * Look in (modules|themes)/.../MANIFEST and top level MANIFEST
	 */
	$base = realpath(dirname(__FILE__) . '/../../..') . '/';
	$list = array();
	if (file_exists($base . 'MANIFEST')) {
	    $list[] = 'MANIFEST';
	}
	foreach (array('modules', 'themes') as $dir) {
	    $dh = opendir($base . $dir);
	    while (($file = readdir($dh)) !== false) {
		if (file_exists($base . $dir . '/' . $file . '/MANIFEST')) {
		    $list[] = $dir . '/' . $file . '/MANIFEST';
		}
	    }
	    closedir($dh);
	}
	$manifest = array();
	foreach ($list as $file) {
	    $lines = file($base . $file);
	    if (!empty($lines)) {
		foreach ($lines as $line) {
		    $line = trim(preg_replace('/#.*/', '', $line));
		    if (empty($line)) {
			continue;
		    }

		    $line = explode("\t", $line);
		    if (count($line) == 2 && $line[0] == 'R') {
			$file = trim($line[1]);
			$manifest[$file] = array('removed' => 1);
		    } else {
			list ($file, $cksum, $cksum_crlf, $size, $size_crlf) = $line;
			$file = trim($file);
			$manifest[$file] =
			    array('checksum' => $cksum,
				  'checksum_crlf' => $cksum_crlf,
				  'size' => $size,
				  'size_crlf' => $size_crlf);
		    }
		}
	    }
	}
	return $manifest;
    }

    /**
     * Validate string is valid format for an email address.
     *
     * @param string email address
     * @return boolean true if valid format
     */
    function isValidEmailString($email) {
	return (preg_match('/^[a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+\.[a-zA-Z]{2,4}$/', $email) > 0);
    }

    /**
     * Verify that the API provided is compatible with the API that we require.
     *
     * We're only compatible if the major numbers are the same, and the required
     * minor number is less than or equal to the provided minor number.
     *
     * @param array required (major, minor)
     * @param array provided (major, minor)
     */
    function isCompatibleWithApi($required, $provided) {
	if (!is_array($required) || !is_array($provided)) {
	    return false;
	}
	if (count($required) != count($provided) || count($required) != 2) {
	    return false;
	}
	for ($i = 0; $i < 1; $i++) {
	    if (!is_int($required[$i]) || !is_int($provided[$i])) {
		return false;
	    }
	}
	if ($required[0] != $provided[0]) {
	    return false;
	}
	if ($required[1] > $provided[1]) {
	    return false;
	}
	return true;
    }

    /**
     * Get all array keys, looking even in arrays contained within the array.
     *
     * @param array
     * @return array of keys
     */
    function arrayKeysRecursive($array) {
	$keys = array();
	foreach ($array as $key => $item) {
	    $keys[] = $key;
	    if (is_array($item) && !empty($item)) {
		$keys = array_merge($keys, GalleryUtilities::arrayKeysRecursive($item));
	    }
	}
	return $keys;
    }

    /**
     * Get a php.ini value and return its boolean value
     *
     * @param string Name of the php.ini value to be retrieved
     * @return bool
     * @static
     */
    function getPhpIniBool($ini_string) {
	$value = ini_get($ini_string);

	if (!strcasecmp('on', $value) || $value == 1 || $value === true) {
	    return true;
	}

	if (!strcasecmp('off', $value) || $value == 0 || $value === false) {
	    return false;
	}

	/* Catchall */
	return false;
    }

    /**
     * Return the id of the search engine currently crawling the site by
     * analyzing the current request.
     *
     * @return string the crawler id, or null if it's a regular user
     */
    function identifySearchEngine() {
	if (!isset($_SERVER['HTTP_USER_AGENT'])) {
	    return null;
	}
	$userAgent = $_SERVER['HTTP_USER_AGENT'];
	if (strstr($userAgent, 'Google')) {
	    return 'google';
	} else if (strstr($userAgent, 'Yahoo')) {
	    return 'yahoo';
	} else if (strstr($userAgent, 'Ask Jeeves')) {
	    return 'askjeeves';
	} else if (strstr($userAgent, 'msnbot')) {
	    return 'microsoft';
	}

	return null;
    }

    /**
     * Return a sanitized version of the given variable from the _SERVER superglobal.
     * @param string the key in the _SERVER superglobal
     * @return string the value
     */
    function getServerVar($key) {
	if (!isset($_SERVER[$key])) {
	    return null;
	}

	$value = $_SERVER[$key];
	GalleryUtilities::sanitizeInputValues($value);
	return $value;
    }

    /**
     * Return a sanitized version of the given variable from the _COOKIE superglobal.
     * @param string the key in the _COOKIE superglobal
     * @return string the value
     */
    function getCookieVar($key) {
	if (!isset($_COOKIE[$key])) {
	    return null;
	}

	/* Fix php HTTP_COOKIE header bug http://bugs.php.net/bug.php?id=32802 */
	GalleryUtilities::fixCookieVars();

	$value = $_COOKIE[$key];
	GalleryUtilities::sanitizeInputValues($value);
	return $value;
    }

    /**
     * Check if G2 is in embedded mode.
     *
     * @return boolean true if G2 is in embedded mode, else false
     */
    function isEmbedded() {
	if (!GalleryDataCache::containsKey('G2_EMBED') || !GalleryDataCache::get('G2_EMBED')) {
	    return false;
	} else {
	    return true;
	}
    }

    /**
     * Fix the superglobal $_COOKIE to conform with RFC 2965
     *
     * We don't use $_COOKIE[$cookiename], because it doesn't conform to RFC 2965 (the
     * cookie standard), i.e. in $_COOKIE, we don't get the cookie with the most specific path for
     * a given cookie name, we get the cookie with the least specific cookie path.
     * This function does it exactly the other way around, to a) fix our cookie/login problems and
     * to b) conform with the RFC.
     * The PHP bug was already fixed in spring 2005, but we will have to deal with broken PHP
     * versions for a long time. See http://bugs.php.net/bug.php?id=32802.
     *
     * Fixes also another PHP cookie bug. PHP doesn't expect the cookie header to have
     * quoted-strings, but they are perfectly legal according to RFC 2965.
     *
     * The third bug fixed here is an MS Internet Explorer (IE) bug. When using default cookie
     * domains (no leading dot, don't set the domain in set-cookie), IE is supposed to return only
     * cookies that have the exact request-host as their domain.
     * Example: Cookies stored in the browser with cookie domains: .example.com, .www.example.com,
     *          example.com, www.example.com
     *          The request-host is www.example.com. Thus, IE should return all those cookies but
     *          the example.com cookie, because it's a default domain cookie and it doesn't match
     *          exactly the request-host. But IE returns the example.com cookie too.
     * As MS decided that it returns the cookie with the best domain-match first (unspecified in
     * RFC 2965), this wouldn't be a problem if PHP didn't select the last cookie in the
     * HTTP_COOKIE header. But with fixCookieVars(), this case is also fixed.
     *
     * This function reevaluates the HTTP Cookie header and populates $_COOKIE with the correct
     * cookies. We fix only non-array and non '[', ']' containing cookies for simplicity. To fix
     * our login problem, we'd have to fix only the GALLERYSID cookie anyway.
     *
     * @param boolean force the reevaluation of the HTTP header string Cookie
     * @param boolean unset static variable for testability
     */
    function fixCookieVars($force=false, $unset=false) {
	static $fixed;
	if (!isset($fixed) || $force) {
	    $fixed = true;
	    if (isset($_SERVER['HTTP_COOKIE']) && !empty($_SERVER['HTTP_COOKIE'])) {
		/*
		 * Array to keep track of fixed cookies to not make the same mistake as php, i.e.
		 * don't assign values to cookies that were already fixed / set before.
		 */
		$fixedCookies = array();
		/* Check if the Cookie header contains quoted-strings */
		if (strstr($_SERVER['HTTP_COOKIE'], '"') === false) {
		    /*
		     * Use fast method, no quoted-strings in the header.
		     * Get rid of less specific cookies if multiple cookies with the same NAME
		     * are present. Do this by going from left/first cookie to right/last cookie.
		     */
		    $tok = strtok($_SERVER['HTTP_COOKIE'], ',;');
		    while ($tok) {
			GalleryUtilities::_registerCookieAttr($tok, $fixedCookies);
			$tok = strtok(',;');
		    }
		} else {
		    /*
		     * We can't just tokenize the Cookie header string because there are
		     * quoted-strings and delimiters in quoted-string should be handled as values
		     * and not as delimiters.
		     * Thus, we have to parse it character by character.
		     */
		    $quotedStringOpen = false;
		    $string = $_SERVER['HTTP_COOKIE'];
		    $len = strlen($string);
		    $i = 0;
		    $lastPos = 0;
		    while ($i < $len) {
			switch ($string{$i}) {
			    /* the two attr-pair separators */
			case ',':
			case ';':
			    if ($quotedStringOpen) {
				/* Ignore separators within quoted-strings */
			    } else {
				/* else, this is an attr[=value] pair */
				GalleryUtilities::_registerCookieAttr(substr($string, $lastPos,
									     $i - $lastPos),
								      $fixedCookies);
				$lastPos = $i+1; /* next attr starts at next char */
			    }
			    break;
			case '"':
			    $quotedStringOpen = !$quotedStringOpen;
			    break;
			case '\\':
			    /* escape the next character = jump over it */
			    $i++;
			    break;
			}
			$i++;
		    }
		    /* register last attr in header, but only if the syntax is correct */
		    if (!$quotedStringOpen) {
			GalleryUtilities::_registerCookieAttr(substr($string, $lastPos),
							      $fixedCookies);
		    }
		}
	    }
	}

	/*
	 * To test methods that call fixCookieVars, we have to first unset the static $fixed
	 * variable to enable testability of these functions.
	 * This way, fixCookieVars will repopulate $_COOKIE on the next call, i.e. it simulates a
	 * case, where fixCookieVars has not been called before on the request.
	 */
	if ($unset) {
	    $fixed = null;
	}
    }


    /**
     * Register a Cookie Var safely
     *
     * Creates an entry in $_COOKIE for $attr, which is a name=value pair.
     * We try to mimic the PHP source code here: make the entry binary safe, don't register
     * non-NAME attributes (e.g. cookie version,...). The one thing we don't do here is treat
     * array cookies correctly because it would but too involving. But we gracefully just don't
     * replace these array cookies in $_COOKIE, so if they are used somewhere, they will be
     * left intact by fixCookieVars().
     *
     * @param string the cookie var attr, NAME [=VALUE]
     * @param array (string already registered cookie name, ...)
     */
    function _registerCookieAttr($attr, &$fixedCookies) {
	global $gallery;
	/*
	 * Split NAME [=VALUE], value is optional for all attributes
	 * but the cookie name
	 */
	if (($pos = strpos($attr, '=')) !== false) {
	    $val = substr($attr, $pos + 1);
	    $key = substr($attr, 0, $pos);
	} else {
	    /* No cookie name=value attr, we can ignore it */
	    return;
	}
	/* Urldecode header data (php-style of name = attr handling) */
	$key = trim(urldecode($key));
	/* Don't accept zero length key */
	if (($len = strlen($key)) == 0) {
	    return;
	}
	/* Don't fix cookies with '[', ']' or any array-cookies (for simplicity) */
	$pos = strchr($key, '[');
	if (strchr($key, '[') !== false || strchr($key, ']') !== false) {
	    return;
	}
	/* Make it a binary safe variable name */
	for ($i = 0; $i < $len; $i++) {
	    if ($key{$i} == ' ' || $key{$i} == '.') {
		$key{$i} = '_';
	    }
	}
	/*
	 * Don't register non-NAME attributes like domain, path, ... which are all
	 * starting with a dollar sign according to RFC 2965.
	 */
	if (strpos($key, '$') === 0) {
	    return;
	}
	/* Urldecode value */
	$val = trim(urldecode($val));
	/* Addslashes if magic_quotes_gpc is on */
	$phpVm = $gallery->getPhpVm();
	if ($phpVm->get_magic_quotes_gpc()) {
	    $key = addslashes($key);
	    $val = addslashes($val);
	}
	if (!isset($fixedCookies[$key])) {
	    $_COOKIE[$key] = $val;
	    $fixedCookies[$key] = true;
	}
    }
}
?>
