<?php
/**
* @module Q
*/
/**
* Functions for validating stuff
* @class Q_Valid
*/
class Q_Valid
{
/**
* Says whether the first parameter is an absolute URL or not.
* @method url
* @static
* @param {string} $url The string to test.
* @param {string} [$check_domain=false] Whether to check the domain, too
* @param {&string} [$fixed_url=null]
* @return {boolean}
*/
static function url(
$url,
$check_domain = false,
&$fixed_url = null)
{
if (!is_string($url)) {
return false;
}
$url_parts = parse_url($url);
if (empty($url_parts['scheme'])
and substr($url, 0, 2) !== '//') {
return false;
}
if ($check_domain) {
if (!self::domain($url_parts['host'])) {
return false;
}
}
// If we are here, it's a URL
$pieces = explode('?', $url);
$fixed_url = $pieces[0];
if (isset($pieces[1])) {
$fixed_url .= '?' . implode('&', array_slice($pieces, 1));
}
return true;
}
/**
* Checks for a valid domain
* @method domain
* @static
* @param {array} [$options=array()]
* Optional. An array that can contain the following keys:
*
* * "checkMX" => if true, will check mx records
* * "allowIP" => if true, allows IP addresses
* @return {boolean}
*/
static function domain (
$domain,
$options = array())
{
if (ip2long($domain) === false or empty($options['allowIP'])) {
// Check if domain is IP. If not, it should be valid domain name
$domain_array = explode(".", $domain);
$count = count($domain_array);
if ($count < 2)
return false; // Not enough parts to domain
for ($i = 0; $i < $count - 1; $i ++) {
if (! preg_match(
"/^(([A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9])|([A-Za-z0-9]+))$/",
$domain_array[$i]))
return false;
}
if (! preg_match("/^[A-Za-z]{2,4}$/", $domain_array[$count - 1]))
return false;
}
if (empty($options['checkMX']))
return true;
// checks for if MX records in the DNS
$mxhosts = array();
if (! self::getmxrr($domain, $mxhosts)) {
// no mx records, ok to check domain
if (! fsockopen($domain, 25, $errno, $errstr, 30))
return false;
return true;
} else {
// mx records found
foreach ($mxhosts as $host)
if (fsockopen($host, 25, $errno, $errstr, 30))
return true;
return false;
}
}
/**
* Checks both files and folders for writability
* @method writeable
* @static
* @param {string} $path
* @return {boolean}
*/
static function writable ($path)
{
// From PHP.NET
// Checks both files and folders for writability
//will work in despite of Windows ACLs bug
//NOTE: use a trailing slash for folders!!!
//see http://bugs.php.net/bug.php?id=27609
//see http://bugs.php.net/bug.php?id=30931
if ($path{strlen($path) - 1} == '/') { // recursively return a temporary file path
return self::writable($path . uniqid(mt_rand()) . '.tmp');
} else if (dir($path)) {
return self::writable($path . '/' . uniqid(mt_rand()) . '.tmp');
}
// check tmp file for read/write capabilities
$rm = file_exists($path);
$f = @fopen($path, 'a');
if ($f === false)
return false;
fclose($f);
if (! $rm)
unlink($path);
return true;
}
/**
* Determines whether a string represents a valid date
* You might want to use checkdate after this, to validate
* this date against the Gregorian calendar.
* @method date
* @static
* @param {string} $date_string The string to test
* @return {boolean|array} Returns false if can't be parsed. Otherwise, an associative array.
*/
static function date ($date_string)
{
$parsed = date_parse($date_string);
if ($parsed['error_count'] > 0)
return false;
return array(
'year' => $parsed['year'],
'month' => $parsed['month'],
'day' => $parsed['day'],
'hour' => $parsed['hour'],
'minute' => $parsed['minute'],
'second' => $parsed['second']
);
}
/**
* Checks for a valid email address
* @method email
* @static
* @param {string} $address The email address to test
* @param {&string} [$normalized_address=null] Will be filled with the string representing the normalized email address
* @param {array} [$options=array()] An array that can contain the following keys:
*
* * "check_mx" => if true, will check mx records
* * "allowIP" => if true, accepts IP addresses after the @
* @return {boolean} Whether the email address seems valid
*/
static function email ($address, &$normalized_address = null, $options = array())
{
// First, we check that there's one @ symbol, and that the lengths are right
if (! preg_match("/^[^@]{1,64}@[^@]{1,255}$/", $address)) {
// Email invalid because wrong number of characters in one section, or wrong number of @ symbols.
return false;
}
// Split it into sections to make life easier
$normalized_address = mb_strtolower($address, 'UTF-8');
// NOTE: strictly speaking, two emails with different case are different,
// but in practice, users are much more likely to mess up the case of an email
// than an ISP is to create two different accounts that differ only by the case.
$address_array = explode("@", $normalized_address, 2);
$local_array = explode(".", $address_array[0]);
for ($i = 0; $i < sizeof($local_array); $i ++) {
if (! preg_match(
"@^(([A-Za-z0-9!#$%&'*+/=?^_`{|}~-][A-Za-z0-9!#$%&'*+/=?^_`{|}~\.-]{0,63})|(\"[^(\\|\")]{0,62}\"))$@",
$local_array[$i])) {
return false;
}
}
if (! self::domain($address_array[1], $options))
return false;
return true;
}
/**
* @method getmxrr
* @static
* @private
* @param {string} $hostname
* @param {&array} $mxhosts
* @return {boolean}
*/
private static function getmxrr ($hostname, &$mxhosts)
{
$mxhosts = array();
exec('%SYSTEMDIRECTORY%\\nslookup.exe -q=mx ' . escapeshellarg($hostname), $result_arr);
foreach ($result_arr as $line) {
if (preg_match("/.*mail exchanger = (.*)/", $line, $matches))
$mxhosts[] = $matches[1];
}
return (count($mxhosts) > 0);
}
/**
* Checks for a valid phone number
* @method phone
* @static
* @beta
* @param {string} $number The phone number to test
* @param {&string} [$normalized_number=null] Will be filled with the string representing the normalized phone number
* @return {boolean} Whether the phone number seems like it could be valid
*/
static function phone ($number, &$number_normalized = null)
{
if (empty($number)) {
return false;
}
// Strip all non numeric, non plus characters from the phone number
$num = "$number";
$stripped = preg_replace('/[^\d\+]+/', '', $num);
if (empty($stripped) or !preg_match('/^\+?\d{5,15}$/', $stripped)) {
return false;
}
if ($stripped[0] !== '+' and strlen($stripped) === 10) {
// we will assume that this number is in north america and has a trunk code of 1
if ($stripped[0] === '1') {
return false;
}
$number_normalized = '1'.$stripped;
} else {
// otherwise, we require the person to input the e.164 of the number themselves
// TODO: replace this with a more extensive way to convert
// inputted numbers into e.164 format
$number_normalized = ($stripped[0] !== '+')
? $stripped
: substr($stripped, 1);
}
$number_normalized = '+' . $number_normalized; // make this function idempotent!
return true;
}
/**
* Use this for validating the nonce
* @method nonce
* @static
* @param {boolean} [$throwIfInvalid=false] If true, throws an exception if the nonce is invalid.
* @param {boolean} [$missingIsValid=false] If true, returns true if request body is missing nonce.
* @throws {Q_Exception_FailedValidation}
*/
static function nonce(
$throwIfInvalid = false,
$missingIsValid = false)
{
if (!isset($_SESSION['Q']['nonce'])) {
return true;
}
$snf = Q_Config::get('Q', 'session', 'nonceField', 'nonce');
$sn = Q_Request::special($snf, null);
if ($missingIsValid and !isset($sn)) {
return true;
}
$gp = array_merge($_GET, $_POST);
$rn = Q_Request::special($snf, null, $gp);
if (!isset($sn) or $_SESSION['Q']['nonce'] != $rn) {
if (!$throwIfInvalid) {
return false;
}
$sameDomain = true;
$baseUrl = Q_Request::baseUrl();
$message = Q_Config::get('Q', 'session', 'nonceMessages', 'sameDomain', null);
if (!empty($_SERVER['HTTP_REFERER'])) {
$host1 = parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST);
$host2 = parse_url($baseUrl, PHP_URL_HOST);
if ($host1 !== $host2) {
$message = Q_Config::get('Q', 'session', 'nonceMessages', 'otherDomain', null);
}
}
$message = Q::interpolate($message, compact('baseUrl'));
$field = 'nonce';
throw new Q_Exception_FailedValidation(compact('message', 'field'), 'Q.nonce');
}
return true;
}
/**
* Validates the signature of the request (from Q_Request::special('sig', null))
* @method signature
* @static
* @param {boolean} [$throwIfInvalid=false] If true, throws an exception if the nonce is invalid.
* @param {array} [$data=$_REQUEST] The data to check the signature of
* @param {array|string} [$fieldKeys] Path of the key under which to save signature
* @return {boolean} Whether the phone number seems like it could be valid
* @throws {Q_Exception_FailedValidation}
*/
static function signature ($throwIfInvalid = false, $data = null, $fieldKeys = null)
{
if (!isset($data)) {
$data = $_REQUEST;
}
$secret = Q_Config::get('Q', 'internal', 'secret', null);
if (!isset($secret)) {
return true;
}
$invalid = true;
if (is_array($fieldKeys)) {
$ref = &$data;
foreach ($fieldKeys as $k) {
if (!isset($k)) {
break;
}
$ref2 = &$ref;
$ref = &$ref[$k];
}
if ($ref) {
$signature = $ref;
unset($ref2[$k]);
$calculated = Q_Utils::signature($data, $secret);
if ($calculated === $signature) {
$invalid = false;
} else { // try with null
$ref2[$k] = null;
$calculated = Q_Utils::signature($data, $secret);
if ($calculated === $signature) {
$invalid = false;
}
}
}
} else {
if (is_string($fieldKeys)) {
$signature = $fieldKeys;
} else {
$sgf = Q_Config::get('Q', 'internal', 'sigField', 'sig');
$signature = Q_Request::special($sgf, null, $data);
}
if ($signature) {
$invalid = false;
$req = $data;
unset($req["Q.$sgf"]);
unset($req["Q_$sgf"]);
if (Q_Utils::signature($req, $secret) !== $signature) {
$invalid = true;
}
}
}
if (!$invalid) {
return true;
}
if ($throwIfInvalid) {
header("HTTP/1.0 403 Forbidden");
$message = Q_Config::get('Q', 'internal', 'sigMessage', "The signature did not match.");
throw new Q_Exception_FailedValidation(compact('message'), array("Q.$sgf", "_[$sgf]"));
}
return false;
}
/**
* Convenience method to require certain fields to be present in an array,
* and generate errors otherwise.
* @method requireFields
* @static
* @param {array} $fields Array of strings or nested arrays of strings, naming fields that are required
* @param {array} [$source=$_REQUEST] Where to look for the fields
* @param {boolean} [$throwIfMissing=false] Whether to throw an exception
* on the first violation, or add them to a list.
* @return {array} The resulting list of exceptions
*/
static function requireFields($fields, $source = null, $throwIfMissing = false)
{
if (!isset($source)) {
$source = $_REQUEST;
}
$result = array();
foreach ($fields as $fieldname) {
$missing = false;
$field = '';
if (is_array($fieldname)) {
$t = $source;
foreach ($fieldname as $f) {
if (!isset($t[$f])) {
$missing = true;
break;
}
$t = $t[$f];
}
if ($missing) {
foreach ($fieldname as $f) {
$field = $field ? $field.'['.$f.']' : $f;
}
}
} else {
if (!isset($source[$fieldname])) {
$missing = true;
}
$field = $fieldname;
}
if ($missing) {
$exception = new Q_Exception_RequiredField(compact('field'), $field);
if ($throwIfMissing) {
throw $exception;
}
$result[] = $exception;
}
}
return $result;
}
}