diff --git a/src/App.php b/src/App.php deleted file mode 100644 index 75d39e6..0000000 --- a/src/App.php +++ /dev/null @@ -1,11 +0,0 @@ -_ =& $members; } + function &__get($key) + { + return $this->_[$key] ?? null; + } + function __set($key, $value) + { + return $this->_[$key] = $value; + } + + + function __isset($key) + { + return isset($this->_[$key]); + } + function __unset($key) + { + unset($this->_[$key]); + } + + + function &all() + { + return $this->_; + } + function set($key, $value) // alias for __set() + { + return $this->_[$key] =& $value; + } + function &get($key, $default = null) // alias for __get() + { + return $this->_[$key] ?? $default; + } + function has($key) // alias for __isset() + { + return isset($this->_[$key]); + } + function &merge($key, array $arr) // we need this function because we cannot (re)`set` the arrays to new values without unsetting the old values first! ie. __set() will fail because it already exists! + { + // TODO: Add is_array() checks to the container, and add variable number of array inputs! + $this->_[$key] = array_merge($this->_[$key], $arr); + return $this->_[$key]; + } + function remove($key) // alias for __unset() + { + unset($this->_[$key]); + } + + + + + function &__call($method, $args) + { + if (isset($this->_[$method])) + if (is_callable($this->_[$method])) + { + $result = call_user_func_array($this->_[$method], $args); + return $result; + } + else + return $this->_[$method]; + else + { + if (preg_match('/^([gs]et|has|isset|unset)([A-Z_])(.*)$/', $method, $match)) + { + $property = strtolower($match[2]). $match[3]; + switch($match[1]) + { + case 'get': return $this->_[$property] ?? $args[0] ?? null; + case 'set': return $this->_[$property] = $args[0]; + case 'has': // fallthrough vvv alias for `isset` + case 'isset': $result = isset($this->_[$property]); return $result; + case 'unset': $result = null; unset($this->_[$property]); return $result; + } + //throw new \InvalidArgumentException("Property {$property} doesn't exist"); + } + throw new \InvalidArgumentException(__CLASS__ . "->{$method}() doesn't exist"); + } + } + // // Workaround for the `array access functions` eg. array_push($obj->toArray(), 'Hello World!'); // @@ -19,11 +98,12 @@ class Collection implements \Iterator, \Countable, \ArrayAccess return $this->_; } public function &__invoke() - { - return $this->_; + { // TODO: What do you think about this technique? We could just leave it if we don't use it! + // Basically, we are calling an internal `__invoke` handler + // eg. $myCollection['__invoke'] = function($c) { return $c->all(); } + return $this->_['__invoke']($this); } - // // Iterator interface // diff --git a/src/Container.php b/src/Container.php index ee4053e..5eed081 100644 --- a/src/Container.php +++ b/src/Container.php @@ -2,42 +2,75 @@ namespace Twister; -class Container implements ContainerInterface +/** + * Multi-purpose IoC/DI Container + * ============================== + * This is a powerful, flexible, yet light-weight, simple and easy-to-use multi-purpose Container; + * it includes the ability to be a IoC/DI Container, a Service Locator, + * a dynamic function library, factory object builder and general purpose data/object/array storage. + * This Container essentially takes the place of an application `Kernel` or `App` class, + * all global variables/object instances, app configurations, environment variables, + * `microservices`, object (factory) builders etc. are all contained within it. + * Many of the services, capabilities and functionality are provided by the various anonymous functions contained within. + * Along with the closures, it also contains arrays and instanciated objects. + * All these capabilities are accessed in the form of a dynamic property (using __get and __set); + * eg. '$c->db' gives you the current database class; you can also use '$c->db()' if you prefer. + */ +class Container { - protected $_container = []; + protected $_container = null; - function __set($name, $value) + function __construct(array $c = []) { - if (isset($this->_container[$name])) + $this->_container = $c; + } + + function __set($key, $value) + { + /** + * do we really need to stop the variables from being set??? + * currently we are protecting anything that is not a callable function. + * the reason why I don't protect a callable, is because many of the callables + * will set the same value to an instantaited object. It just saves us using `unset()` first + * The alternative is to do something like Symfony or other frameworks, + * where we call a `protect()` or `singleton()` methods etc. + * I just hate calling yet another method for every occasion or fringe case! + */ + if (isset($this->_container[$key]) && ! is_callable($this->_container[$key])) { $trace = debug_backtrace(); - trigger_error(__CLASS__ . " container property `{$name}` has already been set and cannot be changed in {$trace[0]['file']} on line {$trace[0]['line']}. Please unset() and re-set the value!", E_USER_ERROR); + trigger_error(__CLASS__ . " container property `{$key}` has already been set and cannot be changed in {$trace[0]['file']} on line {$trace[0]['line']}. Please unset() and re-set the value!", E_USER_ERROR); } else - $this->_container[$name] = $value; + return $this->_container[$key] = $value; } - function &__get($name) + function __get($key) { - if (isset($this->_container[$name])) - return $this->_container[$name]; + if (isset($this->_container[$key])) + { + $value = $this->_container[$key]; + return is_callable($value) ? $value($this) : $value; + } - // Examples of official error messages - // Notice: Undefined index: muscle in C:\...\app.php on line 34 - // Undefined property: app::$conf in C:\...\app.php on line 111
+ /** + * Examples of official PHP error messages when a property cannot be found + * Notice: Undefined index: config in C:\...\app.php on line 34 + * Undefined property: Container::$config in C:\...\app.php on line 111
+ */ $trace = debug_backtrace(); - trigger_error('Undefined container property: ' . __CLASS__ . "->{$name} in {$trace[0]['file']} on line {$trace[0]['line']}; thrown", E_USER_ERROR); + trigger_error('Undefined container property: ' . __CLASS__ . "->{$key} in {$trace[0]['file']} on line {$trace[0]['line']}; thrown", E_USER_ERROR); return null; } - function __isset($name) + function __isset($key) { - return isset($this->_container[$name]); + return isset($this->_container[$key]); } - function __unset($name) + function __unset($key) { - unset($this->_container[$name]); + unset($this->_container[$key]); } function &__call($method, $args) @@ -71,64 +104,28 @@ class Container implements ContainerInterface } throw new \InvalidArgumentException(__CLASS__ . "->{$method}() doesn't exist"); } - - - // old method - if (isset(self::$_[$method]) && is_callable(self::$_[$method])) - return call_user_func_array(self::$_[$method], $args); - if ( ! empty($args)) - self::$_[$method] = $args[0]; - return self::$_[$method]; -/* // old method - if ( ! empty($args)) - self::$_[$method] = $args[0]; - if (isset(self::$_[$method])) - return self::$_[$method]; - else - throw new InvalidArgumentException("_::{$method}() doesn't exist"); - /* - Taken from: https://stackoverflow.com/questions/1279382/magic-get-getter-for-static-properties-in-php - static public function __callStatic($method, $args) - { - if (preg_match('/^([gs]et)([A-Z])(.*)$/', $method, $match)) - { - $reflector = new \ReflectionClass(__CLASS__); - $property = strtolower($match[2]). $match[3]; - if ($reflector->hasProperty($property)) - { - $property = $reflector->getProperty($property); - switch($match[1]) - { - case 'get': return self::${$property->name}; - case 'set': return self::${$property->name} = $args[0]; - } - } - else throw new InvalidArgumentException("Property {$property} doesn't exist"); - } - } - */ } - function set($key, $value) // alias for __set() + function set($key, $value) // alias for __set() { - return $this->_container[$key] =& $value; + return $this->__set($key, $value); } - function &get($key) // alias for __get() + function &get($key, $default = null) // similar to __get() { - return $this->_container[$key]; + return $this->_container[$key] ?? $default; } - function has($key) // alias for __isset() + function has($key) // alias for __isset() { return isset($this->_container[$key]); } - function &merge($key, array $arr) // we need this function because we cannot (re)`set` the arrays to new values without unsetting the old values first! ie. __set() will fail because it already exists! + function &merge($key, array $arr) // we need this function because we cannot (re)`set` the arrays to new values without unsetting the old values first! ie. __set() will fail because it already exists! { // TODO: Add is_array() checks to the container, and add variable number of array inputs! $this->_container[$key] = array_merge($this->_container[$key], $arr); return $this->_container[$key]; } - function remove($key) // alias for __unset() + function remove($key) // alias for __unset() { unset($this->_container[$key]); } diff --git a/src/ContainerAwareTrait.php b/src/ContainerAwareTrait.php index 39b74a0..4c80465 100644 --- a/src/ContainerAwareTrait.php +++ b/src/ContainerAwareTrait.php @@ -17,5 +17,6 @@ trait ContainerAwareTrait public function setContainer(ContainerInterface $container = null) { $this->container = $container; + return $this; } } diff --git a/src/Controller.php b/src/Controller.php index a9585f7..10b81ac 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -2,13 +2,14 @@ namespace Twister; +/** + * Extending from this base Controller class for Controllers is optional! + * The alternative is to make the `action handlers` static methods in the controller + */ abstract class Controller implements ContainerInterface { use ContainerAwareTrait; - /** - * ContainerAwareTrait - */ function __set($name, $value) { return $this->container->__set($name, $value); } function &__get($name) { return $this->container->__get($name); } function __isset($name) { return $this->container->__isset($name); } diff --git a/src/Db.php b/src/Db.php index 55fda4b..a5bf75f 100644 --- a/src/Db.php +++ b/src/Db.php @@ -2,7 +2,14 @@ namespace Twister; -class Db extends MySQLi +/** + * Just a MySQLi wrapper class + * Adds 2 helper functions I use a lot, lookup() and get_array() + * `lookup()` returns a single value/field from the database, or a single row as an associated array by field names + * `get_array()` returns multiple rows in an associated array + * The other functions and functionality are less useful! + */ +class Db extends mysqli { static function build($module, array $commands = array()) { @@ -284,3 +291,4 @@ class Db extends MySQLi } } + diff --git a/src/ExceptionHandler.php b/src/ExceptionHandler.php new file mode 100644 index 0000000..7db4de2 --- /dev/null +++ b/src/ExceptionHandler.php @@ -0,0 +1,106 @@ +getTrace(); + $sql = null; + foreach ($backtrace as $trace) + { + switch ($trace['function']) + { // Find MySQLi query if there was one! + case 'query': + case 'real_query': + if ( ! isset($trace['class']) || $trace['class'] !== 'mysqli') break; + $sql = $trace['args'][0]; // 1st arg in object style (mysqli::query()) + break; + case 'mysqli_query': + case 'mysqli_real_query': + $sql = $trace['args'][1]; // 2nd arg in procedural style (mysqli_query()) + } + } + if (isset($sql)) + { + $backtrace = array_reverse($backtrace); // reverse the array, it feels a bit more natural to see the stack trace in call order! + $dump .= '
' . PHP_EOL . 'SQL Query Dump:
' . PHP_EOL . '' . htmlentities($sql) . '
' . PHP_EOL; + } + break; + } + + $backtrace = $e->getTrace(); + $max_file_length = 0; + $max_line_length = 0; + $internal_function = '[internal function]'; + foreach($backtrace as $trace) + { + if (isset($trace['file'])) + { + $max_file_length = max($max_file_length, strlen($trace['file'])); + $max_line_length = max($max_line_length, strlen($trace['line'])); + } + else + $max_file_length = strlen($internal_function); + } + $backtrace = array_reverse($backtrace); // reverse the array, it feels a bit more natural to see the stack trace in call order! + $dump .= PHP_EOL . PHP_EOL . 'Stack trace:
' . PHP_EOL;
+			foreach ($backtrace as $index => $trace)
+			{
+				$args	= null;
+				$comma	= null;
+				foreach ($trace['args'] as $arg)
+				{
+					if (is_string($arg))		$args .= $comma . (strpos($arg,'\'')===false?'\'':'"') . (strlen($arg) > 40 ? substr($arg, 0, 40) . ' ...' : $arg) . (strpos($arg,'\'')===false?'\'':'"');
+					else if (is_numeric($arg))	$args .= $comma . $arg;
+					else if (is_bool($arg))		$args .= $comma . $arg; // ($arg ? 'true' : 'false')
+					else if (is_null($arg))		$args .= $comma . 'null';
+					else if (is_array($arg))	$args .= $comma . 'array';
+					else if (is_object($arg))	$args .= $comma . '(object) ' . get_class($arg);
+					else if (is_callable($arg))	$args .= $comma . 'callable';
+					else if (is_resource($arg))	$args .= $comma . 'resource';
+					$comma = ', ';
+				}
+				$dump	.=	str_pad('#' . ($index + 1), 4) .
+									(isset($trace['file']) ? (str_pad($trace['file'], $max_file_length) . ' (line: ' . $trace['line'] . ')' . str_repeat(' ', $max_line_length - strlen($trace['line']))) : str_pad($internal_function, $max_file_length + $max_line_length + 9)) . ' ' .
+									(isset($trace['class']) ? $trace['class'] . $trace['type'] : null) .
+									$trace['function'] . '(' . $args . ')' . PHP_EOL;
+			}
+			$dump		.=	'
'; + + // Detect Content-Type + $headers = headers_list(); + $html = true; + $found_content_type = false; + foreach ($headers as $header) + { + if (strpos($header, 'Content-Type:') !== false || strpos($header, 'Content-type:') !== false) + { + $found_content_type = true; + $html = strpos($header, 'text/html') !== false; + } + } + if ( ! $found_content_type && headers_sent() === false) + header('Content-Type: text/html; charset=utf-8'); + + $dump = '
' . PHP_EOL . + 'Fatal error: Uncaught ' . get_class($e) . ': ' . htmlentities($e->getMessage()) . ' thrown in ' . $e->getFile() . ' on line ' . $e->getLine() . '

' . PHP_EOL . + $dump . PHP_EOL; + + echo $html ? $dump : html_entity_decode(strip_tags($dump)); + exit; + }); + } +} diff --git a/src/PipelineInterface.php b/src/PipelineInterface.php deleted file mode 100644 index 0a12d06..0000000 --- a/src/PipelineInterface.php +++ /dev/null @@ -1,54 +0,0 @@ - - * return $next($request, $response); - * - * - * Middleware MUST return a response, or the result of $next (which should - * return a response). - * - * @param Request $request - * @param Response $response - * @param callable $next - * @return Response - */ - public function __invoke(Request $request, Response $response, callable $next); -} diff --git a/src/Request.php b/src/Request.php new file mode 100644 index 0000000..1f506bd --- /dev/null +++ b/src/Request.php @@ -0,0 +1,630 @@ +container = $container; + $this->db = $container->db; + + $this->_normalize_ip_address(); + $this->_detect_banned_ips(); + + $this->method = strtoupper($_SERVER['REQUEST_METHOD']); + + $this->uri = Uri::fromGlobals(); + + $this->isHttps = $this->is_https = $this->isSecure = $this->uri->isHttps(); + + $this->cc = $this->_get_cc(); + + $this->_get_agent_ex(); + } + + private function _normalize_ip_address() + { + if ( ! isset($_SERVER['REMOTE_ADDR']) || filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP) === false) + die('Invalid $_SERVER[REMOTE_ADDR]'); + + $this->remote_addr = $_SERVER['REMOTE_ADDR']; + $this->inet_pton = inet_pton($this->remote_addr); + $this->ip2hex = bin2hex($this->inet_pton); + $this->ipv4 = ip2long($this->remote_addr); + + $this->isIpv4 = $this->is_ipv4 = $this->ipv4 !== false; + } + + private function _detect_banned_ips() + { + $db = $this->db; + if ($db->lookup('SELECT SQL_CACHE 1 FROM bot_bans WHERE ip = 0x' . $this->ip2hex)) + { + $db->real_query('UPDATE bot_ban_requests SET requests = requests + 1 WHERE ip = 0x' . $this->ip2hex); + $db->close(); + sleep(3); + header('HTTP/1.0 403 Forbidden'); + exit; + } + } + + private function _get_cc() + { + return $this->db->lookup( $this->is_ipv4 ? + ('SELECT SQL_CACHE cc FROM geoip2_ipv4 WHERE ' . $this->ipv4 . ' BETWEEN range_from AND range_until') : + // 'SELECT SQL_CACHE cc FROM geoip2_ipv6 WHERE CONV(HEX(LEFT(0x' . self::$ip2hex . ', 8)), 16, 10) BETWEEN range_high_from AND range_high_until AND CONV(HEX(RIGHT(0x' . self::$ip2hex . ', 8)), 16, 10) BETWEEN range_low_from AND range_low_until LIMIT 1' + ('SELECT SQL_CACHE cc FROM geoip2_ipv6 WHERE 0x' . substr($this->ip2hex, 0, 16) . ' BETWEEN range_high_from AND range_high_until AND 0x' . substr($this->ip2hex, 16) . ' BETWEEN range_low_from AND range_low_until LIMIT 1') + ); + } + + private function _get_agent_ex() // `agent` is just a generic term for `agent`, `forwarded for` and `via` + { + $db = $this->db; + $agent = isset($_SERVER['HTTP_USER_AGENT']) ? db::varchar($_SERVER['HTTP_USER_AGENT'], 700) : null; + $ff = db::varchar(self::_normalize_forwarded_for(), 128); + $via = isset($_SERVER['HTTP_VIA']) ? db::varchar($_SERVER['HTTP_VIA'], 224) : null; + $db->real_query('LOCK TABLES request_agents WRITE, request_forwarded_for WRITE, request_via WRITE'); + $this->agent_id = empty($agent) ? 0 : $this->_get_agent_ex_id($db, $agent, 'request_agents', 'agent'); + $this->forwarded_for_id = empty($ff) ? 0 : $this->_get_agent_ex_id($db, $ff, 'request_forwarded_for', 'forwarded_for'); + $this->via_id = empty($via) ? 0 : $this->_get_agent_ex_id($db, $via, 'request_via', 'via'); + $db->real_query('UNLOCK TABLES'); + } + + // Combine all the possible values for 'HTTP_X_FORWARDED_FOR' together + // List taken from: http://blackbe.lt/advanced-method-to-obtain-the-client-ip-in-php/ + private static function _normalize_forwarded_for() + { + $HTTP_CLIENT_IP = isset($_SERVER['HTTP_CLIENT_IP']) ? trim((string) $_SERVER['HTTP_CLIENT_IP']) : null; + $HTTP_X_FORWARDED_FOR = isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? trim((string) $_SERVER['HTTP_X_FORWARDED_FOR']) : null; + $HTTP_X_FORWARDED = isset($_SERVER['HTTP_X_FORWARDED']) ? trim((string) $_SERVER['HTTP_X_FORWARDED']) : null; + $HTTP_X_CLUSTER_CLIENT_IP = isset($_SERVER['HTTP_X_CLUSTER_CLIENT_IP']) ? trim((string) $_SERVER['HTTP_X_CLUSTER_CLIENT_IP']) : null; + $HTTP_FORWARDED_FOR = isset($_SERVER['HTTP_FORWARDED_FOR']) ? trim((string) $_SERVER['HTTP_FORWARDED_FOR']) : null; + $HTTP_FORWARDED = isset($_SERVER['HTTP_FORWARDED']) ? trim((string) $_SERVER['HTTP_FORWARDED']) : null; + $result = array(); + if (!empty($HTTP_CLIENT_IP)) $result[] = $HTTP_CLIENT_IP; + if (!empty($HTTP_X_FORWARDED_FOR)) $result[] = $HTTP_X_FORWARDED_FOR; + if (!empty($HTTP_X_FORWARDED)) $result[] = $HTTP_X_FORWARDED; + if (!empty($HTTP_X_CLUSTER_CLIENT_IP)) $result[] = $HTTP_X_CLUSTER_CLIENT_IP; + if (!empty($HTTP_FORWARDED_FOR)) $result[] = $HTTP_FORWARDED_FOR; + if (!empty($HTTP_FORWARDED)) $result[] = $HTTP_FORWARDED; + return preg_replace('/[, ]+/', ',', implode(',', $result)); + } + + private function _get_agent_ex_id($db, $value, $table, $field) + { + $md5 = md5($value); + $id = $db->lookup('SELECT id FROM ' . $table . ' WHERE hash = 0x' . $md5); + if ( ! $id) + { + $id = $db->call('CALL spGetRandomID("id", "' . $table . '", 1, 0x7FFFFFFF)'); + // TEMPORARY HACK, while we examine the various 'forwarded_for' values! + // I want to know which of the various `forwarded_for` combinations are used on the internet! + // So these are just `bitmasks` of various possible fields! Once we establish which ones are used, we can remove the bit fields and the $_SERVER[] array member in normalize_forwarded_for() + if ($field == 'forwarded_for') + // REMOVE THIS SECTION when our 'testing' is complete! We should determine what 'forwarded_for' server variables are used, and reduce it! + $db->real_query('INSERT IGNORE INTO request_forwarded_for (id, hash, forwarded_for, client_ip, x_forwarded_for, x_forwarded, x_cluster_client_ip, forwarded_for2, forwarded) VALUES (' . $id . ', 0x' . $md5 . ', ' . $db->escape($value) . ', ' . (int) empty($_SERVER['HTTP_CLIENT_IP']) . ', ' . (int) empty($_SERVER['HTTP_X_FORWARDED_FOR']) . ', ' . (int) empty($_SERVER['HTTP_X_FORWARDED']) . ', ' . (int) empty($_SERVER['HTTP_X_CLUSTER_CLIENT_IP']) . ', ' . (int) empty($_SERVER['HTTP_FORWARDED_FOR']) . ', ' . (int) empty($_SERVER['HTTP_FORWARDED']) . ')'); + else + // Leave only this statement after we've tested the various 'forwarded_for' combinations! + $db->real_query('INSERT IGNORE INTO ' . $table . ' (id, hash, ' . $field . ') VALUES (' . $id . ', 0x' . $md5 . ', ' . $db->escape($value) . ')'); + } + return $id; + } + + public function execute_route() + { + $path = $this->uri->path; + $paths = explode('/', $path, 4); + $route = null; + $method = $this->method; + // $this->route = null; // general (debug) information about the matching route + $this->routes = $routes = require __DIR__ . '/../config/routes.php'; + // $this->params = null; + $controller = $routes['routes'][$paths[1]] ?? $routes[404]; + // $matches = null; + + if (is_array($controller)) + { + $arr = $controller; + $controller = $routes[404]; + + if (isset($arr[0]) && is_array($arr[0])) // test the first array item, continue if it's another array, if it's NOT an array, then it's a single route path processed below, this $arr[0] will be null or a string like 'GET' in the `single route/path` version + { + foreach ($arr as $route) + { + if ( ! isset($route[0]) || strpos($route[0], $method) !== false) + { + $regexp = '~^' . (strpos($route[1], '/') === 0 ? null : '/' . $paths[1] . '/') . $this->_convert_route_pattern($route[1]) . '$~'; + if (preg_match($regexp, $path, $this->params) === 1) + { + $this->route['regexp'] = $regexp; // store the `winning` regexp + $this->route['command'] = $route[1]; // store the `winning` (route) command of the regexp + // $this->params = $matches; + $controller = $route[2]; + break; // break from the foreach loop! + } + } + } + } + else if (is_null($arr[0]) || strpos($arr[0], $method) !== false) + { + $regexp = '~^' . (empty($arr[1]) ? '/' . $paths[1] : ((strpos($arr[1], '/') === 0 ? null : '/' . $paths[1] . '/') . $this->_convert_route_pattern($arr[1]))) . '$~'; // by default if we leave the route/command null, we use /login instead of /login/ like `/admin/...` basically this works differently to the array (sub-folder) version for arrays, where we append /name/ but here we only do /name + if (preg_match($regexp, $path, $this->params) === 1) + { + $this->route['regexp'] = $regexp; + $this->route['command'] = $arr[1]; + // $this->params = $matches; + $controller = $arr[2]; + } + } + } + + $this->route['controller'] = $controller; + + + //_::params($matches); // AKA _::request()['params'] = $params; + + if (is_string($controller)) + { + if (($pos = strpos($controller, '::')) !== false) // controller is a static class method + { + $class = substr($controller, 0, $pos); + $method = substr($controller, $pos + 2); + require __DIR__ . '/../controllers/' . $class . '.php'; + $reflection = new \ReflectionMethod($class, $method); + return $reflection->invokeArgs(null, $this->_get_args_from_params($reflection->getParameters())); + } + else if (($pos = strpos($controller, '->')) !== false) // controller is an instantiatable object, usually an object extending the base Controller class + { + $class = substr($controller, 0, $pos); + $method = substr($controller, $pos + 2); + require __DIR__ . '/../controllers/' . $class . '.php'; + $reflection = new \ReflectionMethod($class, $method); + $obj = new $class($this->container); + return $reflection->invokeArgs($obj, $this->_get_args_from_params($reflection->getParameters())); + } + else + { + //(require __DIR__ . '/../controllers/' . $controller . '.php')($params); + $result = require __DIR__ . '/../controllers/' . $controller . '.php'; + if (is_callable($result)) // controller is (possibly) a callable function + { + $reflection = new \ReflectionFunction($result); + return $reflection->invokeArgs($this->_get_args_from_params($reflection->getParameters())); + } + // else // controller is public/global code already executed directly inside the included file + } + } + else if (is_callable($controller)) + { + $reflection = new \ReflectionFunction($controller); + return $reflection->invokeArgs($this->_get_args_from_params($reflection->getParameters())); + } + else if (is_array($controller)) + { + // TODO + die('array controller is not implemented yet'); + } + else + { + die('unknown/unsuported controller type'); + } + + //(require __DIR__ . '/../controllers/' . $controller . '.php')($params); + } + + // + // This function expands routes like '/admin/article/{id}' ==> '/admin/article/(?[0-9]+)' + // It also converts TRAILING `optional` paths to the preg equivalent: '/club/{id}[/history]' ==> '/club/(?[0-9]+)(?:/history)?' + // + private function _convert_route_pattern($route) + { + if (strrpos($route, ']', -1) !== false) // check if the route ends with optional parameters (where last character in route is `]`) + { + // Check matching number of opening & closing brackets ... disabled only for performance reasons, the preg_match() will also throw an exception!?!? wtf? + //if (substr_count($route, '[') !== substr_count($route, ']')) + // throw new \Exception('Number of opening `[` and closing `]` do not match in route: ' . $route); + $depth = 0; + $count = 0; // counts character for substr(), so we're not appending on every character, although routes are generally very short! + $optionals = ''; + $i = strlen($route) - 1 - 1; // we already know the last character is ], so skip it + while($route[$i] === ']') $i--; // now find the first NON ] character from the back, eg. /admin[/] ... $i = 7 `/` ... /admin[opt2[opt1]] + for (; $i >= 0; $i-- ) + { + if ($route[$i] == '[') + { + if ($depth-- == 0) // found the opening `[` for an optional parameter! + { + $optionals = '(?:' . substr($route, $i + 1, $count) . $optionals . ')?'; + $count = -1; // the $count++ below will reset it to 0 + $depth = 0; // reset counter, $depth is currently -1 ($depth-- == 0 means we test $depth for 0 BEFORE the decrement, leaving it as -1 after) + } + } + else if ($route[$i] == ']') + $depth++; + $count++; + } + $route = substr($route, 0, $count) . $optionals; + } + + $patterns = $this->routes['patterns']; + + // replace matching named patterns + return preg_replace_callback( + '~\{\s*([a-zA-Z_][a-zA-Z0-9_-]*)\s*(?::\s*([^{}]*(?:\{(?-1)\}[^{}]*)*))?\}~', + function ($matches) use ($patterns) { + return '(?<' . $matches[1] . '>' . (isset($matches[2]) ? (isset($patterns[$matches[2]]) ? $patterns[$matches[2]] : $matches[2]) : (isset($patterns[$matches[1]]) ? $patterns[$matches[1]] : '[^/]+')) . ')'; + }, + $route + ); + } + + // Dynamic route controller::handler argument builder + private function _get_args_from_params(array $params) + { + $byType = [ 'container' => &$this->container, + 'db' => &$this->db, + 'request' => &$this, + // 'user' => &$this->container->user + ]; + $args = []; + foreach ($params as $param) + { + if ($param->hasType() && isset($byType[$type = strtolower($param->getType())])) + $args[] = $byType[$type]; + else if (isset($_GET[$param->name])) + $args[] = $_GET[$param->name]; + else if (isset($this->params[$param->name])) + $args[] = $this->params[$param->name]; + else if (isset($_POST[$param->name])) + $args[] = $_POST[$param->name]; + else + { + if ( ! $param->isOptional()) + throw new \Exception('Unable to find NON-optional parameter `' . $param->name . '` for route controller/handler: ' . var_export($this->route, true)); + $args[] = $param->getDefaultValue(); + } + } + return $args; + } + + /** + * Searches the $_POST and $_GET arrays for an item, or returns a default value + * @param string $key Used as a key for reading an array item value + * @param mixed $default The default value returned if the item does not exist + * @return mixed + */ + function get($key, $default = null) + { + return $_GET[$key] ?? $this->params[$key] ?? $_POST[$key] ?? $_REQUEST[$key] ?? $default; + // return $post && isset($_POST[$name]) ? $_POST[$name] : ($get && isset($_GET[$name]) ? $_GET[$name] : $default); + } + + function getPost($key, $default = null) + { + return $_POST[$key] ?? $default; + } + + function getCookie($key, $default = null) + { + return $_COOKIE[$key] ?? $default; + } + + // Helper functions; implemented by Slim + function isGet() + { + return $this->method === 'GET'; + } + function isPost() + { + return $this->method === 'POST'; + } + function isPut() + { + // In order to support Restful API's on browsers that only support GET and POST, ... + // Notes taken from Slim: https://www.slimframework.com/docs/objects/request.html + // `It is possible to fake or override the HTTP request method. This is useful if, for example, you need to mimic a PUT request using a traditional web browser that only supports GET or POST requests.` + // `There are two ways to override the HTTP request method. You can include a _METHOD parameter in a POST request’s body. The HTTP request must use the application/x-www-form-urlencoded content type.` + // PSEUDO CODE: if (header 'Content-type' == 'application/x-www-form-urlencoded') && get(_METHOD) === 'PUT' + return $this->method === 'PUT'; + } + function isDelete() + { + return $this->method === 'DELETE'; + } + function isHead() + { + return $this->method === 'HEAD'; + } + function isPatch() + { + return $this->method === 'PATCH'; + } + function isOptions() + { + return $this->method === 'OPTIONS'; + } + + + + + // eg. request::build_url(array('scheme' => null)) == //host/path + // eg. request::build_url(array('scheme' => 'https', 'host' => 'example.com', 'path' => '/login')) == https://example.com/login + // eg. request::build_url(array('path' => '/login')) == http://host/login (NOTE: Any `default` (request::$query) query string will be ignored when `path` is specified!) + // eg. request::build_url(array('query' => 'test=123')) == //host/path?test=123 + // eg. request::build_url(array('query' => '')) == //host/path <<== removes the query string completely! + // eg. request::build_url(array('scheme' => 'https', 'path' => '/')); <== This ALSO clears any query string! + // SHORTHAND examples + // eg. request::build_url('//') === request::build_url(array('scheme' => null)) + // eg. request::build_url('') === request::build_url(array('scheme' => null)) + // eg. request::build_url('https') === request::build_url(array('scheme' => 'https')) + // eg. request::build_url('/') === request::build_url(array('path' => '/')) + // eg. request::build_url('test=123') === request::build_url(array('query' => 'test=123')) + function build_url( $parts = null ) + { + if (is_string($parts)) + { + if ($parts === '' || $parts === '//') + return $this->uri->withScheme(null); // '//' . $this->uri->authority . $this->uri->getPathAndQuery(); + if ($parts[0] === '/') + return $this->uri->getLeftPart(Uri::PARTIAL_AUTHORITY) . $parts; //(_::is_https() ? 'https://' : 'http://') . _::request('host') . $parts; + if ($parts === 'https' || $parts === 'http') + return $this->uri->withScheme($parts); //$parts . '://' . _::request('host') . _::request('uri'); + if ($parts[0] === '?') + return $this->uri->getLeftPart(Uri::PARTIAL_PATH) . $parts; //(_::is_https() ? 'https://' : 'http://') . _::request('host') . _::request('path') . $parts; + if (strpos($parts, '=') !== false) + return $this->uri->getLeftPart(Uri::PARTIAL_PATH) . '?' . $parts; // (_::is_https() ? 'https://' : 'http://') . _::request('host') . _::request('path') . '?' . $parts; + if ($parts[0] === '#') + return $this->uri->withFragment($parts); // (_::is_https() ? 'https://' : 'http://') . _::request('host') . _::request('uri') . $parts; + die('Invalid call to Request::build_url("' . $parts . '")'); + } + return (string) (new Uri($parts)); +/* + return (isset($parts['scheme']) ? (empty($parts['scheme']) ? '//' : $parts['scheme'] . '://') : ($this->uri->scheme ?? 'http') : 'http://')) . // Can set `scheme` to NULL to generate a Protocol-Relative-URL! + '//' . (empty($parts['host']) ? $this->uri->authority : $parts['host']) . + (empty($parts['path']) ? _::request('path') : $parts['path']) . + (isset($parts['query']) ? (empty($parts['query']) ? null : '?' . $parts['query']) : (empty($parts['path']) && _::request('query') ? '?' . _::request('query') : null)); // NO FRAGMENT! We can append it if we really need it! +*/ + } + + // eg. request::redirect('https') eg. https://host/path?id=1 + // eg. request::redirect('/') eg. / + // eg. request::redirect('?test=123') eg. /path?test=123 ... NEW query string + // eg. request::redirect('&test=123') eg. /path?id=1&test=123 ... append to query string + // eg. request::redirect('/path?test=123', '?test=456') eg. /path?test=456 ... OVERWRITES query string + // eg. request::redirect('/path?test=123', '?fail=456') eg. /path?fail=456 ... OVERWRITES query string + // eg. request::redirect('/hello-world') + // eg. request::redirect('/login', 'error=Invalid Username') + // eg. request::redirect('/login', $errors) `$errors = ['error' => 'Invalid Username']` + // eg. request::redirect('/new-home', 301) + // eg. request::redirect(['scheme' => 'https', 'host' => 'example.com', 'path' => '/login']) == https://example.com/login + // eg. request::redirect(['path' => '/login']) == /login + // eg. request::redirect(['scheme' => 'https', 'path' => '/']); + + // Function competely re-written and added to the request class on 21 May 2017 + function redirect( /* mixed */ ) + { + /* + Taken from: http://www.php.net/manual/en/function.header.php#78470 + + // 301 Moved Permanently + header("Location: /foo.php",TRUE,301); + + // 302 Found + header("Location: /foo.php",TRUE,302); + header("Location: /foo.php"); + + // 303 See Other + header("Location: /foo.php",TRUE,303); + + // 307 Temporary Redirect + header("Location: /foo.php",TRUE,307); + + "The HTTP status code changes the way browsers and robots handle redirects, so if you are using header(Location:) it's a good idea to set the status code at the same time. Browsers typically re-request a 307 page every time, cache a 302 page for the session, and cache a 301 page for longer, or even indefinitely. Search engines typically transfer "page rank" to the new location for 301 redirects, but not for 302, 303 or 307." + + "If the status code is not specified, header('Location:') defaults to 302." + */ + $http_response_code = 302; + $location = null; + $args = func_get_args(); + if (count($args) === 1 && is_string($args[0])) + { + $arg = $args[0]; + switch ($arg[0]) + { + case '/': + $location = $arg; // $arg == '/path' || $arg == '//host/path' + break; + case 'h': + if ($arg === 'http' || $arg === 'https') + $location = (string) $this->uri->withScheme($arg); // '//' . $this->uri->authority . $this->uri->getPathAndQuery(); + // $location = $arg . '://' . _::request('host') . _::request('uri'); + else + $location = $arg; // $arg == 'http://www.google.com/' + break; + case '?': + $location = $this->uri->getLeftPart(Uri::PARTIAL_PATH) . $parts; //(_::is_https() ? 'https://' : 'http://') . _::request('host') . _::request('path') . $parts; + //$location = _::request('path') . $arg; + break; + case '&': // append to query string + $location = $this->uri->getLeftPart(Uri::PARTIAL_QUERY); + $location .= (strpos($location, '?') === false ? '?' . substr($arg, 1) : $arg); + // $location .= ($location->query === null ? '?' . substr($arg, 1) : $arg); // alternative using Uri object! + //$location = _::request('uri') . (empty(_::request('query')) ? '?' . substr($arg, 1) : $arg); + break; + case '#': + $location = $this->uri->getLeftPart(Uri::PARTIAL_QUERY); + // $location = _::request('uri') . $arg; + break; + default: + $location = $arg; + } + } + else + { + throw new \Exception('Request section not implemented/RE-written yet!'); + + /* + // Note: This is not necessary because the redirect header accepts a path based address; eg. / ... so the scheme & host are NOT always required! IOW we don't need to build a FULL / fully qualified URL like the `build_url()` function! + $location = [ 'scheme' => self::$https ? 'https' ; 'http', + 'host' => self::$host, + 'path' => self::$path, + 'query' => self::$query + ]; + */ + foreach ($args as $arg) + { + if (is_string($arg)) + { + switch (substr($arg, 0, 1)) // pseudo: if $arg = '' then $arg[0] == `Notice: Uninitialized string offset: 0` ... IOW: We cannot use `switch($arg[0])` because we could get a PHP notice message! + { + case '/': + if (substr($arg, 0, 2) === '//') // `//` eg. '//www.google.com/' ... ie. same protocol! + $location = parse_url($arg); + else // `/` eg. '/login' ... ie. relative URL + { + $qmark = strpos($arg, '?'); + if ($qmark === false) // eg. request::redirect('https', '/login') ... ie. the `https` will set scheme, host, path and query. We only want scheme, host and path! NOT the query string! + { + $location['path'] = $arg; + unset($location['query']); + } + else // eg. request::redirect('/login?id=123', '?id=456') + { + $location['path'] = substr($arg, 0, $qmark); + $location['query'] = substr($arg, $qmark + 1); + } + } + break; + case '?': + // if ( ! isset($location['path'])) // optional + // $location['path'] = self::$path; + $location['query'] = substr($arg, 1); // overwrite existing query string OR create/set query string + break; + case '&': + // if ( ! isset($location['path'])) // optional + // $location['path'] = self::$path; + $location['query'] = (empty($location['query']) ? substr($arg, 1) : $location['query'] . $arg); // append to existing query string OR create new query string if it doesn't exist + break; + case '#': + // if ( ! isset($location['path'])) // optional + // $location['path'] = self::$path; + $location['fragment'] = substr($arg, 1); // should it be possible to just set the fragment without anything else? + break; + case 'h': + if ($arg === 'http' || $arg === 'https') + { + $location = [ 'scheme' => $arg, + 'host' => _::request('host'), + 'path' => _::request('path'), + 'query' => _::request('query') + ]; + break; + } + // Alternative version below would have checked the prefix value of $arg for a `http:/` or `https:` value in the beginning to `semi-validate` the parse_url() value. + //$substr = substr($arg, 0, 6); // `optional` alternative + //if ($substr === 'http:/' || $substr === 'https:') + //{ + $location = parse_url($arg); + break; + //} + // fallthrough vvv if NOT `http://` or `https://` + default: + die('Invalid value in request::redirect(): ' . print_r($arg, true)); + /* + if (strpos($arg, '=') !== false) // eg. 'test=123' == append to query string + { + $location = [ 'scheme' => $arg, + 'host' => self::$host, + 'path' => self::$path, + 'query' => self::$query + ]; + } + */ + } + } + else if (is_int($arg)) + $http_response_code = $arg; + else if (is_array($arg)) + { + if (is_array($location)) + { + if (isset($location['query']) && isset($arg['path']) && ! isset($arg['query'])) // eg. request::redirect('?id=123', ['path' => '/']) ... result = '/' ... because the '/' is a PATH without query string! The new `arg` value has a PATH but NOT a query string! path = query string = request_uri ... they go hand-in-hand! + unset($location['query']); + $location = array_merge($location, $arg); + } + else + $location = $arg; + } + else + die('Invalid value in request::redirect(): ' . print_r($arg, true)); + } + + if (is_array($location)) + { + throw new \Exception('Request section not implemented/RE-written yet!'); + + if (isset($location['scheme']) && ! isset($location['host'])) + $location['host'] = _::request('host'); + + if (isset($location['user']) || isset($location['pass'])) + $location['host'] = (isset($location['user']) ? $location['user'] : null) . (isset($location['pass']) ? ':' . $location['pass'] : null) . '@' . (isset($location['host']) ? $location['host'] : _::request('host')); + + if (isset($location['host']) && ! isset($location['path'])) + $location['path'] = '/'; + + $location = (isset($location['scheme']) ? $location['scheme'] . ':' : null) . + (isset($location['host']) ? '//' . $location['host'] . (isset($location['port']) ? ':' . $location['port'] : null) : null) . + (isset($location['path']) ? $location['path'] : null) . + (isset($location['query']) ? '?' . $location['query'] : null) . + (isset($location['fragment']) ? '#' . $location['fragment'] : null); + } + else + die('Invalid value in request::redirect(): ' . print_r($args, true)); + } + +die('redirecting to: ' . $location . ''); + + // ob_get_length() will return "FALSE if no buffering is active." ... so all we want to know is if buffering IS active AND there was already data sent to the buffer ... then we need to die()! + if (ob_get_length()) die(ob_get_clean() . '

Errors on Redirect to: ' . $location . '

'); + if ( ! headers_sent($filename, $linenum)) + { + if ( ! empty($location) ) header('Location: ' . $location, true, $http_response_code); + else echo '

ERROR: Empty location on redirect()!

'; + exit; + } + die('

ERROR: Headers already sent in "' . $filename . '" on line ' . $linenum . '
' . + 'Cannot redirect, please click the following link: ' . $location . '

'); + } + + +} diff --git a/src/Router.php b/src/Router.php deleted file mode 100644 index 39d739c..0000000 --- a/src/Router.php +++ /dev/null @@ -1,8 +0,0 @@ - 80, + 'https' => 443, + 'ftp' => 21, // ftp://[user[:password]@]host[:port]/url-path + 'mailto' => true, // mailto:name@email.com + 'file' => true, // file://host/path file://localhost/etc/fstab == file:///etc/fstab file://localhost/c:/WINDOWS/clock.avi == file:///c:/WINDOWS/clock.avi + 'php' => true // php://stdin, php://stdout and php://stderr, php://input etc. http://php.net/manual/en/wrappers.php.php + ]; + + // currently unused! + static $authorityPrefix = [ + 'http' => '//', + 'https' => '//', + 'ftp' => '//', // ftp://[user[:password]@]host[:port]/url-path + 'mailto' => null, // mailto:name@email.com + 'file' => '//', // file://host/path file://localhost/etc/fstab == file:///etc/fstab file://localhost/c:/WINDOWS/clock.avi == file:///c:/WINDOWS/clock.avi + 'php' => '//' // php://stdin, php://stdout and php://stderr, php://input etc. http://php.net/manual/en/wrappers.php.php + ]; + + /** + * Array of supported hash algoithms, initialized to hash_algos() on first use! + * Used when generating dynamic hash properties eg. $this->md5 + * Some algorithms cannot be used such as `gost-crypto`, `tiger128,3` etc. because of invalid characters in the name. + * We could create a mapping from `gost-crypto` to `gost_crypto` and `tiger128,3` to `tiger128_3` etc. replacing invalid characters with underscores + * But that would require looping through the array, and most of those algorithms are not very useful for Uri hashing. + * http://php.net/manual/en/function.hash-algos.php + * @var string[] Array of supported hash algoithms as array keys for fast lookup with isset(), used to test for dynamic hash properties eg. $this->md5 + */ + static $hashAlgos = null; + + /** + * Currently UNUSED: cannot use `gost-crypto`, `tiger128,3` etc. + * Maybe we can create a mapping from `gost-crypto` to `gost_crypto` and `tiger128,3` to `tiger128_3` etc. replacing invalid characters with underscore + */ + /* + static $validHashAlgos = [ 'md2', 'md4', 'md5', + 'sha1', 'sha224', 'sha256', 'sha384', 'sha512', + 'ripemd128', 'ripemd160', 'ripemd256', 'ripemd320', + 'whirlpool', 'snefru', 'snefru256', 'gost', + 'adler32', 'crc32', 'crc32b', 'fnv132', 'fnv1a32', 'fnv164', 'fnv1a64', 'joaat' + ]; + */ + + /** + * Idea taken from CakePHP: https://api.cakephp.org/2.3/class-CakeRequest.html#$_detectors + * https://api.cakephp.org/2.3/source-class-CakeRequest.html#92-117 + * + * @var mixed[] Array of built in detectors used with is($type) or is$type(), can be modified with addDetector(). + */ + // TODO + static $_detectors = null; // ['dotcom' => function(&$uri){return substr($uri->host, -4) === '.com'}] eg. isDotCom() || is('DotCom') || is('.com') (the '.com' version cannot be tested with is.com()!) + // ['domain' => function(&$uri, $domain){return substr($uri->host, -strlen($uri->host)) === $domain}] // isDomain('example.com') || isDomain('.com') || is('domain', '.com') + /** + * TODO: Not implemented yet! + * Idea to support `Trusted Proxes` and the "X-Forwarded-Proto" header, so isSecure() will return true even for 'http' requests that are routed through them + * See: http://api.symfony.com/2.3/Symfony/Component/HttpFoundation/Request.html#method_setTrustedProxies + * @var bool[] + */ + static $trustedProxies = []; + + /** + * `PHP uses a non-standards compliant practice of including brackets in query string fieldnames` eg. foo[]=1&foo[]=2&foo[]=3 ==> ['foo' => ['1', '2', '3']] + * the CGI standard way of handling duplicate query fields is to create an array, but PHP's parse_str() silently overwrites them; eg. foo=1&foo=2&foo=3 ==> ['foo' => '3'] + * This parameter will + * @var bool + */ + static $standardArrays = false; + + /** + * URI string cache ~ reset to null when any property changes + * + * @var null|string + */ + protected $_uri = null; + + /** + * @var null|string + */ + protected $_scheme = null; + + /** + * @var null|string + */ +// protected $_authority = null; + + /** + * @var null|string + */ +// protected $_userInfo = null; + + /** + * @var null|string + */ + protected $_user = null; + + /** + * @var null|string + */ + protected $_pass = null; + + /** + * @var null|string + */ + protected $_host = null; + + /** + * @var null|int + */ + protected $_port = null; + + /** + * @var null|string + */ + protected $_path = null; + + /** + * @var null|string + */ + protected $_query = null; + + /** + * @var null|string + */ + protected $_frag = null; + + + /** + * @param string $uri + * @throws InvalidArgumentException on non-string $uri argument + */ + public function __construct() + { + switch (func_num_args()) + { + case 0: + // $_parts['original'] = null; + return; + case 1: + $args = func_get_args(); + if (is_string($args[0])) + { + if ($args[0] === 'https' || $args[0] === 'http') + { + self::fromGlobals($this); + $this->_scheme = $args[0]; + return; + } + else + { + $this->parse($args[0]); + return; + } + } + else if (is_array($args[0])) + $parts = &$args[0]; + else if ($args[0] instanceof self) + { + // Copy constructor + $uri = &$args[0]; + $this->_uri = $uri->_uri; + $this->_scheme = $uri->_scheme; + $this->_user = $uri->_user; + $this->_pass = $uri->_pass; + $this->_host = $uri->_host; + $this->_port = $uri->_port; + $this->_path = $uri->_path; + $this->_query = $uri->_query; + $this->_frag = $uri->_frag; + return; + } + else + throw new InvalidArgumentException(sprintf( + 'Invalid argument type passed to Uri constructor; expecting a string or Uri object, received "%s"', + (is_object($args[0]) ? get_class($args[0]) : gettype($args[0])) + )); + break; + + case 2: + + $args = func_get_args(); + if ($args[0] instanceof self && $args[1] instanceof self) + { // TODO: Build a Uri from base and relative Uri (`Initializes a new instance of the Uri class based on the combination of a specified base Uri instance and a relative Uri instance.`) + // https://msdn.microsoft.com/en-us/library/ceyeze4f(v=vs.110).aspx + + } + else // I want to support a constructor that takes a baseUri/absoluteUri and relativeUri eg. 'http://example.com/admin/' + '../login' => 'http://example.com/login' + { + $parts = &$args; // instead of creating a new array, use the existing one! Basically 'scheme' and 'host' will be added to $args + $parts['scheme'] = &$args[0]; + $parts['host'] = &$args[1]; + } + break; + + case 3: + case 4: + case 5: + + // Build a Uri string with 'scheme' + 'host' + ('path' + 'query' + 'fragment') + // Does not support `user`, `pass` and `port` + $args = func_get_args(); + $parts = &$args; + $parts['scheme'] = &$args[0]; + $parts['host'] = &$args[1]; + if (isset($args[2])) + { + $parts['path'] = &$args[2]; + if (isset($args[3])) + { + $parts['query'] = &$args[3]; + if (isset($args[4])) + $parts['fragment'] = &$args[4]; + } + } + break; + + default; + throw new InvalidArgumentException( + 'Too many arguments passed to Uri constructor!' + ); + } + $this->fromArray($parts); + } + + public static function fromGlobals(&$ptr = null) + { + static $uri; + if (empty($uri)) + { + if (empty($_SERVER['HTTP_HOST'])) + throw new \Exception('$_SERVER[HTTP_HOST] is empty'); + + if (empty($_SERVER['REQUEST_URI'])) + throw new \Exception('$_SERVER[REQUEST_URI] is empty'); + + if ( ! isset($_SERVER['QUERY_STRING'])) // QUERY_STRING can be empty! + throw new \Exception('$_SERVER[QUERY_STRING] is not set'); + + $uri = new Uri(); + + $uri->_scheme = ($_SERVER['HTTPS'] ?? null) === 'on' ? 'https' : 'http'; + + $uri->_host = empty($_SERVER['HTTP_HOST']) ? (empty($_SERVER['SERVER_NAME']) ? getenv('SERVER_NAME') : $_SERVER['SERVER_NAME']) : strtolower($_SERVER['HTTP_HOST']); + + if (self::isNonStandardPort($uri->_scheme, $uri->_host, (int) ($_SERVER['SERVER_PORT'] ?? 0))) + $uri->_port = (int) $_SERVER['SERVER_PORT']; + + $uri->_path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); // alternative 1 + // $uri->_path = parse_url(rawurldecode($_SERVER['REQUEST_URI']), PHP_URL_PATH) // alternative 2 + // $uri->_path = ($qpos = strpos($_SERVER['REQUEST_URI'], '?')) !== false ? substr($_SERVER['REQUEST_URI'], 0, $qpos) : $_SERVER['REQUEST_URI']; // alternative 3 + // $uri->_path = preg_replace('#^[^/:]+://[^/]+#', '', $_SERVER['REQUEST_URI']); // from marshalRequestUri(), but this includes the query string // alternative 4 + + if ( ! empty($_SERVER['QUERY_STRING'])) + $uri->_query = $_SERVER['QUERY_STRING']; + } + + if ($ptr === null) + return clone $uri; + + if ($ptr instanceof self) + { + $ptr->_uri = (string) $uri; + $ptr->_scheme = $uri->_scheme; + $ptr->_user = $uri->_user; + $ptr->_pass = $uri->_pass; + $ptr->_host = $uri->_host; + $ptr->_port = $uri->_port; + $ptr->_path = $uri->_path; + $ptr->_query = $uri->_query; + $ptr->_frag = $uri->_frag; + } + else if (is_array($ptr)) + { + $ptr['scheme'] = $uri->_scheme; + $ptr['user'] = $uri->_user; + $ptr['pass'] = $uri->_pass; + $ptr['host'] = $uri->_host; + if (isset($uri->_port)) + $ptr['port'] = $uri->_port; + $ptr['path'] = $uri->_path; + if ($uri->_query) + $ptr['query'] = $uri->_query; + if ($uri->_frag) + $ptr['fragment'] = $uri->_frag; + } + return $ptr; + } + + // Alias of fromGlobals() to support Symfony + public static function createFromGlobals(&$ptr = null) + { + return self::fromGlobals($ptr); + } + + public function reset() + { + $this->_uri = null; + $this->_scheme = null; + $this->_user = null; + $this->_pass = null; + $this->_host = null; + $this->_port = null; + $this->_path = null; + $this->_query = null; + $this->_frag = null; + } + + public function isDomain($domain) // eg. isDomain('.com') || isDomain('example.com') ... NOTE: For performance reasons, we don't strtolower() the $domain! + { + if ($this->_host === null) return false; + return substr($this->_host, -strlen($this->_host)) === $domain; + } + public function isHost($host) // alias of isDomain() + { + return $this->isDomain($host); + } + + /** + * Strip the query string from a path + * Taken from zend-diactoros ServerRequestFactory.php on line 368 + * + * @param mixed $path + * @return string + */ + static function stripQueryString($path) + { + return ($qpos = strpos($path, '?')) !== false ? substr($path, 0, $qpos) : $path; + } + + /** + * TODO: Not implemented yet, just returns $qs + * + * Idea taken from http://api.symfony.com/2.3/Symfony/Component/HttpFoundation/Request.html#method_normalizeQueryString + * `It builds a normalized query string, where keys/value pairs are alphabetized, have consistent escaping and unneeded delimiters are removed.` + * + * @param mixed $path + * @return string + */ + static function normalizeQueryString(string $qs) + { + return $qs; + } + + /** + * Returns an array of the host split by '.' + * + * @return string[] Array of host value split by '.', can be an IPv4 address or Domain with TLD's + */ + public function splitHost() // AKA explodeHost() + { + return explode('.', $this->_host ?? ''); + } + + /** + * Returns an array of the path split by '/' + * + * @return string[] Array of path value split by '/', remember that absolute paths start with /, so the first array value is usually an empty string! + */ + public function splitPath() // AKA explodePath() + { + return explode('/', $this->_path ?? ''); + } + + /** + * Parse a URI into its component parts, and set the properties + * + * @param string $uri + */ + private function parse($uri) + { + $parts = parse_url($uri); + + if ($parts === false) + throw new \InvalidArgumentException("The source URI `{$uri}` appears to be malformed"); + + // special handling of mailto: scheme + if (($parts['scheme'] ?? null) === 'mailto') + { + if (isset($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment']) || isset($parts['query']) || isset($parts['port']) || empty($parts['path']) || $parts['path'][0] === '/') + throw new \InvalidArgumentException("The source email address `{$uri}` appears to be malformed"); + + $parts = parse_url('mailto://' . $parts['path']); // we 'fool' the parser by adding '//' before the email address, this allows parse_url() to detect the 'authority` components (user@domain) + + if ($parts === false) + throw new \InvalidArgumentException("The source email address `{$uri}` appears to be malformed"); + } + + $this->fromArray($parts); + } + + /** + * Parse a URI array into its parts, and set the properties, $parts must be compatible with parse_url() + * + * @param array $parts + */ + public function fromArray(array $parts) + { // runs parameters through __set() + $this->scheme = $parts['scheme'] ?? null; + $this->user = $parts['user'] ?? null; + $this->pass = $parts['pass'] ?? null; + $this->host = $parts['host'] ?? null; + $this->port = $parts['port'] ?? null; + $this->path = $parts['path'] ?? null; + $this->query = $parts['query'] ?? null; + $this->fragment = $parts['fragment'] ?? null; + } + + /** + * builds/returns an array compatible with the result from parse_url() + * + * @param string $uri + * @return array compatible with parse_url() + */ + public function &toArray() + { + $arr = []; + + if ($this->_scheme !== null) + $arr['scheme'] = $this->_scheme; + + if ($this->_user !== null) + $arr['user'] = $this->_user; + + if ($this->_pass !== null) + $arr['pass'] = $this->_pass; + + if ($this->_host !== null) + $arr['host'] = $this->_host; + + if ($this->_port !== null) + $arr['port'] = $this->_port; + + if ($this->_path !== null) + $arr['path'] = $this->_path; + + if ($this->_query !== null) + $arr['query'] = $this->_query; + + if ($this->_frag !== null) + $arr['fragment'] = $this->_frag; + + return $arr; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * Similar to Symfony isSecure() http://api.symfony.com/2.3/Symfony/Component/HttpFoundation/Request.html#method_isSecure + * Symfony has additional checks, so if an 'http' request is routed via X-Forwarded-Proto through a `Trusted Proxy` then it also returns true! + * + * @return bool $this->scheme === 'https'; + */ + public function isSecure() + { + return $this->_scheme === 'https'; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return bool $this->scheme === 'https'; + */ + public function isHttps() + { + return $this->_scheme === 'https'; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return bool $this->scheme === 'http'; + */ + public function isHttp() + { + return $this->_scheme === 'http'; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return bool $this->scheme === 'http' || $this->scheme === 'https'; + */ + public function isHttpOrHttps() + { + return $this->_scheme === 'http' || $this->_scheme === 'https'; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return bool $this->scheme === 'ftp'; + */ + public function isFtp() + { + return $this->_scheme === 'ftp'; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return bool $this->scheme === 'https'; + */ + public function isMailto() + { + return $this->_scheme === 'mailto'; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return bool $this->scheme === 'file'; + */ + public function isFile() + { + return $this->_scheme === 'file'; + } + + /** + * Return the string representation as a URI reference. + * + * Depending on which components of the URI are present, the resulting + * string is either a full URI or relative reference according to RFC 3986, + * Section 4.1. The method concatenates the various components of the URI, + * using the appropriate delimiters: + * + * - If a scheme is present, it MUST be suffixed by ":". + * - If an authority is present, it MUST be prefixed by "//". + * - The path can be concatenated without delimiters. But there are two + * cases where the path has to be adjusted to make the URI reference + * valid as PHP does not allow to throw an exception in __toString(): + * - If the path is rootless and an authority is present, the path MUST + * be prefixed by "/". + * - If the path is starting with more than one "/" and no authority is + * present, the starting slashes MUST be reduced to one. + * - If a query is present, it MUST be prefixed by "?". + * - If a fragment is present, it MUST be prefixed by "#". + * + * @see http://tools.ietf.org/html/rfc3986#section-4.1 + * @return string + */ + public function __toString() + { + if ($this->_uri === null) + { + $uri = &$this->_uri; + + if ($this->_scheme !== null) + $uri .= $this->_scheme . ':'; + + if ($this->_host !== null) + $uri .= ($this->_scheme !== 'mailto' ? '//' : null) . $this->authority; // warning: `mailto:user@example.com` doesn't have the '//' prefix! + + if ($this->_path !== null) + { + if ($this->_path[0] !== '/' && $this->_host !== null) + $uri .= '/'; + + $uri .= $this->_path; + + //$uri .= static::encodePath($this->path); // Zend version + } + else if ($this->_host !== null && ($this->_query || $this->_frag)) + $uri .= '/'; + + if ($this->_query !== null) + $uri .= '?' . $this->_query; // $uri .= '?' . static::encodeQueryFragment($this->query); ZEND + + if ($this->_frag !== null) + $uri .= '#' . $this->_frag; // $uri .= "#" . static::encodeQueryFragment($this->fragment); ZEND + } + return $this->_uri; + } + + + /** + * {@inheritdoc} + */ + public function getScheme() + { + return $this->_scheme ?: ''; + } + + /** + * {@inheritdoc} + */ + public function getAuthority() + { + return $this->authority ?: ''; + } + + /** + * {@inheritdoc} + */ + public function getUserInfo() + { + return $this->userInfo ?: ''; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + */ + public function getUser() + { + return $this->_user ?: ''; + // return empty($this->userInfo) ? '' : (($pos = strpos($this->userInfo, ':')) === false ? $this->userInfo : substr($this->userInfo, 0, $pos)); + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + */ + public function getPassword() + { + return $this->_pass ?: ''; + // return empty($this->userInfo) ? '' : (($pos = strpos($this->userInfo, ':')) === false ? '' : substr($this->userInfo, $pos + 1)); + } + + /** + * {@inheritdoc} + */ + public function getHost() + { + return $this->_host ?: ''; + } + + /** + * {@inheritdoc} + */ + public function getPort() + { + return self::isNonStandardPort($this->_scheme, $this->_host, $this->_port) + ? $this->_port + : null; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * .NET Framework always returns the default port number, even if it's not given in the URI. + * So I just want to have a function that returns the default port number when one isn't specified! + * + * `The port number defines the protocol port used for contacting the server referenced in the URI. + * If a port is not specified as part of the URI, the Port property returns the default value for the protocol. + * If there is no default port number, this property returns -1.` + * + * @return integer + */ + public function getRealPort() + { + return $this->_port ?: (self::$allowedSchemes[$this->_scheme] ?? -1); + } + + /** + * {@inheritdoc} + */ + public function getPath() + { + return $this->_path ?: ''; + } + + /** + * {@inheritdoc} + */ + public function getQuery() + { + return $this->_query ?: ''; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * Alias for getQuery() to support Symfony syntax + */ + public function getQueryString() + { + return $this->_query ?: ''; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * Based on the Zend framework Uri class method with the same name! + * https://framework.zend.com/manual/2.4/en/modules/zend.uri.html#getting-the-query-part-of-the-uri + */ + public function getQueryAsArray() + { + parse_str($this->_query, $result); + return $result; + } + + /** + * {@inheritdoc} + */ + public function getFragment() + { + return $this->_frag ?: ''; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * Idea taken from the Uri property `PathAndQuery` in .NET Framework: https://msdn.microsoft.com/en-us/library/system.uri.pathandquery.aspx + * AKA REQUEST_URI + */ + public function getPathAndQuery() + { + return $this->_path . ($this->_query === null ? null : '?' . $this->_query); + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * Taken from: https://msdn.microsoft.com/en-us/library/system.uri.getleftpart.aspx + * + * `The GetLeftPart method returns a string containing the leftmost portion of the URI string, ending with the portion specified by part.` + * + * Taken from MSDN: `Gets the specified portion of a Uri instance.` + */ + public function getLeftPart($part) + { + $new = new static(); + // $uri = null; + if (is_string($part)) + { + switch ($part) + { + case 'scheme': $part = self::PARTIAL_SCHEME; break; + case 'authority': $part = self::PARTIAL_AUTHORITY; break; + case 'path': $part = self::PARTIAL_PATH; break; + case 'query': $part = self::PARTIAL_QUERY; break; + default: + throw new \InvalidArgumentException("Invalid part `{$part}` for Uri->getLeftPart(); Only `scheme`, `authority`, `path` and `query` are valid!"); + } + } + switch ($part) + { + case self::PARTIAL_QUERY: + + $new->_query = $this->_query; + + // if ($this->_query !== null) + // $uri = '?' . $this->_query; + + case self::PARTIAL_PATH: + + $new->_path = $this->_path; + + // if ($this->_path !== null) + // $uri = ($this->_path[0] === '/' ? null : '/') . $this->_path . $uri; + // else if ($this->_host !== null) + // $uri = '/' . $uri; + + case self::PARTIAL_AUTHORITY: + + $new->_user = $this->_user; + $new->_pass = $this->_pass; + $new->_host = $this->_host; + $new->_port = $this->_port; + // $new->authority = $this->authority; + + // if ($this->_host !== null) + // $uri = ($this->_scheme === 'mailto' ? null : '//') . $this->authority . $uri; + + case self::PARTIAL_SCHEME: + + $new->_scheme = $this->_scheme; + + // if ($this->_scheme !== null) + // $uri = $this->_scheme . ':' . $uri; + } + return $new; + // return $uri; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * Taken from: https://msdn.microsoft.com/en-us/library/ms131572.aspx + * + * This version accepts a `result` pointer (if func_num_args() === 2) + * which will be set/reset with the current object and returns true/false on success/failure, + * or if result is NOT provided (if func_num_args() === 1) then will return the new object directly! + * + * Taken from MSDN: + * `Creates a new Uri using the specified String instance and a UriKind.` + * `If this method returns true, the new Uri is in result.` + * + * @return bool + */ + public function tryCreate($uri, &$result = null) // TODO + { + $parts = parse_url($uri); + if ($parts === false || empty($parts)) + return func_num_args() === 1 ? $result : false; + + switch (func_num_args()) + { + case 1: + case 2: + if ($result instanceof self) + { + + } + else + { + + } + default: + } + + return func_num_args() === 1 ? $result : true; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * Similar to .NET Finalize; frees memory associated with the Uri + * + */ + public function finalize() + { + if ($this->_uri === null) + $this->_uri = (string) $this; +/* + $this->_scheme = null; + $this->_user = null; + $this->_pass = null; + $this->_host = null; + $this->_port = null; + $this->_path = null; + $this->_query = null; + $this->_freq = null; +*/ + return $this; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * My idea, just a wrapper around preg_match() + */ + public function match($pattern, &$matches = null, $flags = 0, $offset = 0) + { + return preg_match($pattern, (string) $this, $matches, $flags, $offset); + } + /** + * NON-PSR-7 function added by Trevor Herselman + */ + public function matchHost($pattern, &$matches = null, $flags = 0, $offset = 0) + { + return preg_match($pattern, $this->_host ?: '', $matches, $flags, $offset); + } + /** + * NON-PSR-7 function added by Trevor Herselman + */ + public function matchPath($pattern, &$matches = null, $flags = 0, $offset = 0) + { + return preg_match($pattern, $this->_path ?: '', $matches, $flags, $offset); + } + /** + * NON-PSR-7 function added by Trevor Herselman + */ + public function matchQuery($pattern, &$matches = null, $flags = 0, $offset = 0) + { + return preg_match($pattern, $this->_query ?: '', $matches, $flags, $offset); + } + + + /** + * NON-PSR-7 function added by Trevor Herselman + * Taken from: https://msdn.microsoft.com/en-us/library/system.uri.segments.aspx + * + * Taken from MSDN: + * `The Segments property returns an array of strings containing the "segments" (substrings) that form the URI's absolute path. + * The first segment is obtained by parsing the absolute path from its first character until you reach a slash (/) or the end of the path. + * Each additional segment begins at the first character after the preceding segment, and terminates with the next slash or the end of the path. + * (A URI's absolute path contains everything after the host and port and before the query and fragment.)` + * + * `Note that because the absolute path starts with a '/', the first segment contains it and nothing else.` + */ + public function getSegments() + { + return $this->segments; + + /* + // Alternative version using explode() and array internal pointers + if ($this->path !== null) + { + $segments = explode('/', $this->path); + if (empty(end($segments))) + array_pop($segments); // this normally indicates that the last character was a '/', which created an empty string in the last array member + else + prev($segments); // reverse the internal pointer one place (from the end), because we have a 'file' name and we don't want to append '/' to something like 'file.php' + for ( ;($key = key($segments)) !== null; prev($segments)) + $segments[$key] .= '/'; + } + else + return []; + */ + + // old version that excludes the '/' ... we could modify this with a foreach loop that adds the '/' to the end of each string! + //return explode('/', $this->path ?: ''); + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return string[] Returns an array of the `host` name split into DNS segments; eg. example.com => ['example', '.com'] + */ + public function getDnsSegments() + { + return $this->dnsSegments; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * Similar output to the getSegments() function but excludes all the trailing '/' + */ + public function getSegmentsExplode() + { + if ($this->_path === null) + return []; + + $segments = explode('/', $this->_path); + + if (empty(end($segments))) + array_pop($segments); // this normally indicates that the last character was a '/', which created an empty string in the last array member + + return $segments; + } + + /** + * {@inheritdoc} + */ + public function withScheme($scheme) + { + if ( ! is_string($scheme) && $scheme !== null) + throw new InvalidArgumentException(sprintf( + '%s expects a string argument; received %s', + __METHOD__, + (is_object($scheme) ? get_class($scheme) : gettype($scheme)) + )); + + $new = clone $this; + $new->scheme = $scheme; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withUserInfo($user, $password = null) + { + if ( ! is_string($user)) + throw new InvalidArgumentException(sprintf( + '%s expects a string user argument; received %s', + __METHOD__, + (is_object($user) ? get_class($user) : gettype($user)) + )); + + if ($password !== null && ! is_string($password)) + throw new InvalidArgumentException(sprintf( + '%s expects a string password argument; received %s', + __METHOD__, + (is_object($password) ? get_class($password) : gettype($password)) + )); + + $new = clone $this; + $new->userInfo = $user . ($password ? ':' . $password : null); + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withHost($host) + { + if ( ! is_string($host)) + throw new InvalidArgumentException(sprintf( + '%s expects a string argument; received %s', + __METHOD__, + (is_object($host) ? get_class($host) : gettype($host)) + )); + + $new = clone $this; + $new->host = $host; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withPort($port) + { + if ( ! is_numeric($port) && $port !== null) + throw new InvalidArgumentException(sprintf( + 'Invalid port "%s" specified; must be an integer, an integer string, or null', + (is_object($port) ? get_class($port) : gettype($port)) + )); + + if ($port !== null) + $port = (int) $port; + + if ($port === $this->port) + return clone $this; // Do nothing if no change was made. + + if ($port !== null && $port < 1 || $port > 65535) + throw new InvalidArgumentException(sprintf( + 'Invalid port "%d" specified; must be a valid TCP/UDP port', + $port + )); + + $new = clone $this; + $new->port = $port; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withPath($path) + { + if ( ! is_string($path)) + throw new InvalidArgumentException(sprintf( + '%s expects a string argument; received %s', + __METHOD__, + (is_object($path) ? get_class($path) : gettype($path)) + )); + + if (strpos($path, '?') !== false) + throw new InvalidArgumentException( + 'Invalid path provided; paths must not contain a query string' + ); + + if (strpos($path, '#') !== false) + throw new InvalidArgumentException( + 'Invalid path provided; paths must not contain a URI fragment' + ); + + $new = clone $this; + $new->path = $path; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withQuery($query) + { + if ( ! is_string($query)) + throw new InvalidArgumentException(sprintf( + '%s expects a string argument; received %s', + __METHOD__, + (is_object($query) ? get_class($query) : gettype($query)) + )); + + if (strpos($query, '#') !== false) + throw new InvalidArgumentException( + 'Query string must not include a URI fragment' + ); + + $new = clone $this; + $new->query = $query; + + return $new; + } + + /** + * {@inheritdoc} + */ + public function withFragment($fragment) + { + if ( ! is_string($fragment)) + throw new InvalidArgumentException(sprintf( + '%s expects a string argument; received %s', + __METHOD__, + (is_object($fragment) ? get_class($fragment) : gettype($fragment)) + )); + + $new = clone $this; + $new->fragment = $fragment; + + return $new; + } + + /** + * Create a new Uri from parts array into its parts, and set the properties, $parts must be compatible with parse_url() + * + * @param array $parts + */ + public function withArray(array $parts = null) + { // WARNING: Cannot use ?? because we might want to set some parts explicity to null, eg. ['fragment' => null] + // UPDATE: we could do this: ['fragment' => ''] + $new = new static(); + $new->scheme = isset($parts['scheme']) ? $parts['scheme'] : $this->_scheme; + $new->user = isset($parts['user']) ? $parts['user'] : $this->_user; + $new->pass = isset($parts['pass']) ? $parts['pass'] : $this->_pass; + $new->host = isset($parts['host']) ? $parts['host'] : $this->_host; + $new->port = isset($parts['port']) ? $parts['port'] : $this->_port; + $new->path = isset($parts['path']) ? $parts['path'] : $this->_path; + $new->query = isset($parts['query']) ? $parts['query'] : $this->_query; + $new->fragment = isset($parts['fragment']) ? $parts['fragment'] : $this->_fragment; + return $new; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return $this; + */ + public function setScheme($scheme) + { + $this->scheme = $scheme; + return $this; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return $this; + */ + public function setUserInfo($user = null, $password = null) + { + $this->user = $user; + $this->pass = $password; + return $this; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return $this; + */ + public function setHost($host) + { + $this->host = $host; + return $this; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return $this; + */ + public function setPort($port) + { + $this->port = $port; + return $this; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return $this; + */ + public function setPath($path) + { + $this->path = $path; + return $this; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return $this; + */ + public function setQuery($query) + { + $this->query = $query; + return $this; + } + + /** + * NON-PSR-7 function added by Trevor Herselman + * + * @return $this; + */ + public function setFragment($fragment) + { + $this->fragment = $fragment; + return $this; + } + + /** + * Is a given port non-standard for the current scheme? + * WARNING: This retarded function from Zend requires $port to be an integer, or "$port !== self::$allowedSchemes[$scheme]" will fail! I was testing it against $_SERVER['SERVER_PORT'] which is a string! + * + * @param string $scheme + * @param string $host + * @param int $port + * @return bool + */ + static function isNonStandardPort($scheme, $host, $port) + { + if ( ! $scheme) + return ($host && ! $port) ? false : true; + + if ( ! $host || ! $port) + return false; + + return ! isset(self::$allowedSchemes[$scheme]) || $port !== self::$allowedSchemes[$scheme]; + } + + /** + * Name taken from .NET Framework: https://msdn.microsoft.com/en-us/library/system.uri.isdefaultport.aspx + * + * @return bool + */ + public function isDefaultPort(int $port = null) + { + $port = $port ?: $this->port; + return $port === null || $port === (self::$allowedSchemes[$this->_scheme] ?? null); + } + + /** + * Name taken from .NET Framework: https://msdn.microsoft.com/en-us/library/system.uri.gethashcode.aspx + * By default it uses `crc32`, which I think returns the hex values, but I think .NET returns the integer value, problem is with the negative values !?!? + * + * @return int + */ + public function getHashCode(string $algo = 'crc32', bool $raw_output = false) + { + return hash($algo, (string) $this, $raw_output); + } + + /** + * MODIFIED by Trevor Herselman on 1 July 2017 @ 7pm. Changed the preg_replace to substr() and moved the `if empty()` test before the strtolower() + * + * Encodes the scheme to ensure it is a valid scheme. + * + * @param string $scheme Scheme name. + * + * @return string Encoded scheme. + */ + private function encodeScheme($scheme) + { + if (strpos($scheme, ':') !== false) + $scheme = substr($scheme, 0, strpos($scheme, ':')); // remove trailing : and optional trailing '//' characters ... eg. ('http://' || 'http:') -> 'http' + if (empty($scheme)) + return null; + $scheme = strtolower($scheme); + + if ( ! array_key_exists($scheme, self::$allowedSchemes)) + throw new InvalidArgumentException(sprintf( + 'Unsupported scheme "%s"; must be any empty string or in the set (%s)', + $scheme, + implode(', ', array_keys(self::$allowedSchemes)) + )); + + return $scheme; + } + + /** + * Encodes the path of a URI to ensure it is properly encoded. + * + * @param string $path + * @return string + */ + + /** + * Encode the path + * + * Will replace all characters which are not strictly allowed in the path + * part with percent-encoded representation + * + * @param string $path + * @return string + */ + public static function encodePath($path) + { + $path = preg_replace_callback( + '/(?:[^' . self::CHAR_UNRESERVED . ':@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u', + __CLASS__ . '::urlEncodeChar', + $path + ); + + if (empty($path)) + return null; // No path + + if ($path[0] !== '/') + return $path; // Relative path + + // Ensure only one leading slash, to prevent XSS attempts. + return '/' . ltrim($path, '/'); + } + + /** + * Encode a query string to ensure it is propertly encoded. + * + * Ensures that the values in the query string are properly urlencoded. + * + * @param string $query + * @return string + */ + private function encodeQuery($query, $arg_separator = '&') + { + if ( ! empty($query) && strpos($query, '?') === 0) + $query = substr($query, 1); + + $parts = explode($arg_separator, $query); + foreach ($parts as $index => $part) + { + list($key, $value) = $this->splitQueryValue($part); + if ($value === null) + { + $parts[$index] = $this->encodeQueryOrFragment($key); + continue; + } + $parts[$index] = sprintf( + '%s=%s', + $this->encodeQueryOrFragment($key), + $this->encodeQueryOrFragment($value) + ); + } + + return implode($arg_separator, $parts); + } + + /** + * Encodes a fragment value to ensure it is properly encoded. + * + * @param null|string $fragment + * @return string + */ + private function encodeFragment($fragment) + { + if ( ! empty($fragment) && $fragment[0] === '#') + $fragment = '%23' . substr($fragment, 1); + + return $this->encodeQueryOrFragment($fragment); + } + + /** + * Encodes a query string key or value, or a fragment. + * + * @param string $value + * @return string + */ + private function encodeQueryOrFragment($value) + { + return preg_replace_callback( + '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u', + __CLASS__ . '::urlEncodeChar', + $value + ); + } + + /** + * URL encode a character returned by a regex. + * + * @param array $matches + * @return string + */ + private static function urlEncodeChar(array $matches) + { + return rawurlencode($matches[0]); + } + + /** + * Idea taken from: https://msdn.microsoft.com/en-us/library/system.uri.checkhostname.aspx + * `The CheckHostName method checks that the host name provided meets the requirements for a valid Internet host name. + * It does not, however, perform a host-name lookup to verify the existence of the host.` + * + * @param string $value + * @return string + */ + private function checkHostName() // this is a static method in .NET framework + { + // todo + } + + /** + * Split a query value into a key/value tuple. + * + * @param string $value + * @return array A value with exactly two elements, key and value + */ + private function splitQueryValue($value) + { + $data = explode('=', $value, 2); + if (count($data) === 1) + $data[] = null; + return $data; + } + + /** + * Added by Trevor Herselman on 2 July 2017 + * Originally taken from: http://php.net/manual/en/function.parse-str.php#76792 + * + * @param string $str + * @return array key-value pairs + */ + static function proper_parse_str($str) + { + // result array + $arr = array(); + + // split on outer delimiter + $pairs = explode('&', $str); + + // loop through each pair + foreach ($pairs as $i) + { + // split into name and value + list($name,$value) = explode('=', $i, 2); + + // if name already exists + if( isset($arr[$name]) ) + { + // stick multiple values into an array + if( is_array($arr[$name]) ) + $arr[$name][] = $value; + else + $arr[$name] = array($arr[$name], $value); + } + // otherwise, simply stick it in a scalar + else + $arr[$name] = $value; + } + + // return result array + return $arr; + } + + /** + * Added by Trevor Herselman on 2 July 2017 + * Originally taken from: http://php.net/manual/en/function.parse-str.php#76792 + * UNFINISHED updated version, just added $arg_separator so far + * + * @param string $value + * @return array A value with exactly two elements, key and value + */ + static function proper_parse_str_new($str, $arg_separator = '&') + { + // result array + $arr = array(); + + // split on outer delimiter + $pairs = explode($arg_separator, $str); + + // loop through each pair + foreach ($pairs as $i) + { + // split into name and value + list($name,$value) = explode('=', $i, 2); + + // if name already exists + if( isset($arr[$name]) ) + { + // stick multiple values into an array + if( is_array($arr[$name]) ) + $arr[$name][] = $value; + else + $arr[$name] = array($arr[$name], $value); + } + // otherwise, simply stick it in a scalar + else + $arr[$name] = $value; + } + + // return result array + return $arr; + } + + + // Helper function + static function isAssocArray($array, $empty = false) + { + if (empty($array)) + return $empty; + + $i = 0; + foreach ($array as $key => $value) + { + if ($key !== $i++) + return true; + } + return false; + } + + // Helper function + static function isSequentialArray($array, $empty = false) + { + if (empty($array)) + return $empty; + + $i = 0; + foreach ($array as $key => $value) + { + if ($key !== $i++) + return false; + } + return true; + } + + // Helper function + private function setParts($name, &$value) + { + if (empty($value)) + { + unset($this->_parts[$name]); + } + else //if ($this->_parts[$name] ?? null !== $value) + { + $this->_parts[$name] = $value; + //$this->_uri = null; + } + } + + function __get($name) + { + switch ($name) + { + // properties ordered by lookup frequency + case 'scheme': return $this->_scheme; + case 'host': return $this->_host; + case 'path': return $this->_path; + case 'query': return $this->_query; + + case 'port': return $this->_port; + case 'user': return $this->_user; + case 'pass': return $this->_pass; + case 'fragment': return $this->_frag; + + //--- End of the common parse_url() properties ---// + + //--- Start of extended parse_url() properties ---// + + case 'userInfo': + + return empty($this->_user) ? null : ($this->_user . (empty($this->_pass) ? null : (':' . $this->_pass))); + + case 'segments': // Similar to System.Uri.Segments Property in .NET Framework: https://msdn.microsoft.com/en-us/library/system.uri.segments.aspx + + $segments = []; // eg. '/' => ['/'], '/admin/login' => ['/', 'admin/', 'login'] + $offset = 0; + while (($pos = strpos($this->_path, '/', $offset)) !== false) + { + $segments[] = substr($this->_path, $offset, $pos - $offset + 1); + $offset = $pos + 1; + } + if (strlen($this->_path) > $offset) + $segments[] = substr($this->_path, $offset); + + return $segments; + + case 'authority': + + if ($this->_host === null) + return null; + + $userInfo = $this->userInfo; + + return ( $userInfo !== null ? $userInfo . '@' : null) . + $this->_host . + (self::isNonStandardPort($this->_scheme, $this->_host, $this->_port) ? ':' . $this->_port : null); + + case 'schemeAndServer': // Idea taken from: https://msdn.microsoft.com/en-us/library/7767559y.aspx + // `The Scheme, Host, and Port data.` + $scheme = $this->_scheme; + if ( ! empty($scheme)) + $scheme .= ':'; + + $host = $this->_host; + if ( ! empty($host)) + { + $host = ($this->_scheme === 'mailto' ? null : '//') . $host; + $port = $this->_port; + if ($port !== null) + $port = ':' . $port; + } + + return empty($scheme) && empty($host) ? null : ($scheme . $host); + + case 'hostAndPort': + + if ($this->_host === null) + return null; + + $result = $this->_host; + + if ($this->_port) + + return $this->_host . ($this->_port !== null ? ':' . $this->_port : null); + +/* + case 'hostAndRealPort': // INCLUDES the default port + + $result = $this->host; + if () + return $result +*/ + + default: // couldn't find the property with common default names; so lets test the property for a mixed-case name or hash algorithm + + if ( ! ctype_lower($name)) + $name = strtolower($name); + + if (self::$hashAlgos === null) + self::$hashAlgos = array_flip(hash_algos()); // set the hash algorithms as keys for faster lookup with isset() instead of in_array()! + + if (isset(self::$hashAlgos[$name])) // we converted the hash name to lowercase above so we can safely support this: $this->Sha256 + return hash($name, (string) $this); + + //--- Start of alias and mixed-case properties ---// + + switch ($name) + { + // possible mixed-case variants `normalized` to lowercase. eg. `Scheme` => `scheme` + case 'scheme': return $this->_scheme; + case 'host': return $this->_host; + case 'path': return $this->_path; + case 'query': return $this->_query; + + case 'user': return $this->_user; + case 'pass': return $this->_pass; + case 'port': return $this->_port; + case 'fragment': return $this->_frag; + + case 'realport': // My alias of .NET `Uri.StrongPort` + case 'strongport': + + // This is called the Uri.StrongPort property in .NET Framework + // `The Port data. If no port data is in the Uri and a default port has been assigned to the Scheme, + // the default port is returned. If there is no default port, -1 is returned.` + // I put this property here because you are free to use ANY mixed/camel/pascal case variant of this property: eg. StrongPort, strongPort, realPort, RealPort etc. + + return $this->_port ?? self::$allowedSchemes[$this->_scheme] ?? -1; + + case 'userinfo': return $this->userInfo; // mixed-case variant + case 'userpass': return $this->userInfo; // Alias of userInfo + case 'segments': return $this->__get($name); // $this->segments; + case 'authority': return $this->__get($name); // $this->authority; + case 'schemeandserver': return $this->schemeAndServer; + case 'hostandport': return $this->hostAndPort; + case 'querystring': return $this->_query; // possible mixed-case variant of `queryString` + case 'username': return $this->_user; + case 'password': return $this->_pass; + case 'uri': return (string) $this; + case 'tostring': return (string) $this; + case 'string': return (string) $this; + } + } + + $trace = debug_backtrace(); + trigger_error('Undefined property: ' . __CLASS__ . "->{$name} in {$trace[0]['file']} on line {$trace[0]['line']}; thrown", E_USER_ERROR); + } + + // requires 13 case matches before an array lookup table becomes viable, unlikely to ever become viable + function __set($name, $value) + { + $this->_uri = null; + + $property = &$name; + + if ( ! ctype_lower($property)) + $property = strtolower($property); + + switch ($property) + { + case 'scheme': + + if (is_string($value)) + { + $scheme = &$value; + if ( ! ctype_lower($scheme)) + { + // Try to fix the (invalid) scheme by removing trailing ':' or '://' and converting to lowercase + // ie. normalize the scheme + // eg. ('Http://' || 'http:') -> 'http' + + if (strpos($scheme, ':') !== false) + $scheme = substr($scheme, 0, strpos($scheme, ':')); + + $scheme = strtolower($scheme); + + // test again - this test is not specifically for lowercase values only, but also for any remaining non-alphanumeric characters + + if ( ! ctype_lower($scheme)) + { + throw new InvalidArgumentException(sprintf( + 'Invalid Uri scheme string; received %s', + $value + )); + } + } + + if ( ! array_key_exists($scheme, self::$allowedSchemes) && ! empty($scheme)) + { + throw new InvalidArgumentException(sprintf( + 'Unsupported Uri scheme "%s"; must be an empty string or in the set (%s)', + $scheme, + implode(', ', array_keys(self::$allowedSchemes)) + )); + } + + $this->_scheme = empty($scheme) ? null : $scheme; + } + else if ($value === null) + { + $this->_scheme = null; + } + else if ($value instanceof self) + { + $this->_scheme = $value->_scheme; + } + else if (is_array($value) && isset($value[$property])) + { + // this will resend the array value through __set('scheme') again for validation! + + $this->scheme = $value[$property]; + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid Uri scheme string provided; expecting a string or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + return $value; + + case 'host': + + if (is_string($value)) + { + $host = &$value; + + if ($host[0] === '/') + { + // strip possible prefix characters '//' + $host = ltrim($host, '/'); + } + + // do other validation here + + $this->_host = empty($host) ? null : $host; + } + else if ($value === null) + { + $this->_host = null; + } + else if ($value instanceof self) + { + $this->_host = $value->_host; + } + else if (is_array($value) && isset($value[$property])) + { + // this will resend the array value through __set('host') again for validation! + + $this->host = $value[$property]; + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid Uri host; expecting a string or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + return $value; + + case 'port': + + if (is_numeric($value)) + { + $port = (int) $value; + + if (self::isNonStandardPort($this->_scheme, $this->_host, $port)) + { + if ($port < 1 || $port > 65535) + { + throw new InvalidArgumentException(sprintf( + 'Invalid port "%d" specified; must be a valid TCP/UDP port', + $port + )); + } + } + else + { + $port = null; + } + + $this->_port = $port; + } + else if ($value === null) + { + $this->_port = null; + } + else if ($value instanceof self) + { + // resent through validation! Mainly for the non-standard-port checks + $this->port = $value->_port; + } + else if (is_array($value) && isset($value[$property])) + { + // resend value through __set('port') for validation! + $this->port = $value[$property]; + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid Uri port provided; expecting a numeric string, integer or null; received %s', + (is_object($value) ? get_class($value) : (is_string($value) ? $value : 'value of type ' . gettype($value))) + )); + } + + return $value; + + case 'path': + + if (is_string($value)) + { + $path = &$value; + + if (strpos($path, '?') !== false) + { + throw new InvalidArgumentException( + 'Invalid path provided; paths must not contain a query string' + ); + } + + if (strpos($path, '#') !== false) + { + throw new InvalidArgumentException( + 'Invalid path provided; paths must not contain a URI fragment' + ); + } + + // do other validation here + + $path = self::encodePath($path); + + $this->_path = empty($path) ? null : $path; + } + else if ($value === null) + { + $this->_path = null; + } + else if ($value instanceof self) + { + $this->_path = $value->_path; + } + else if (is_array($value) && isset($value[$property])) + { + $this->path = $value[$property]; // resend value through __set('path') for validation! + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid Uri path; expecting a string or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + return $value; + + case 'query': + + if (is_string($value)) + { + $query = &$value; + + if (empty($query)) + { + $this->_query = null; + } + else + { + if (strpos($query, '#') !== false) + throw new InvalidArgumentException( + 'Invalid Uri query string provided; query must not contain a URI fragment' + ); + + $this->_query = $this->encodeQuery($query); + } + } + else if ($value === null) + { + $this->_query = null; + } + else if ($value instanceof self) + { + $this->_query = $value->_query; + } + else if (is_array($value) && isset($value[$property])) + { + $this->query = $value[$property]; // resend value through __set('query') for validation! + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid Uri query string provided; expecting a string or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + return $value; + + case 'fragment': + + if (is_string($value)) + { + $fragment = &$value; + + if ($fragment[0] === '#') + $fragment = substr($fragment, 1); + + $this->_frag = $this->encodeFragment($fragment); + } + else if ($value === null) + { + $this->_frag = null; + } + else if ($value instanceof self) + { + $this->_frag = $value->_frag; + } + else if (is_array($value) && isset($value[$property])) + { + // resend value through __set('fragment') for validation! + $this->fragment = $value[$property]; + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid Uri fragment provided; expecting a string or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + return $value; + + case 'user': + + if ($value === null) + { + $this->_user = null; + } + else if (is_string($value)) + { + $user = &$value; + + $user = rawurlencode($user); + + $this->_user = $user; + } + else if ($value instanceof self) + { + $this->_user = $value->_user; + } + else if (is_array($value) && isset($value[$property])) + { + $this->user = $value[$property]; // resend value through __set('user') for validation! + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid username provided; expecting a string or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + return $value; + + case 'pass': + + if ($value === null) + { + $this->_pass = null; + } + else if (is_string($value)) + { + $pass = &$value; + + $pass = rawurlencode($pass); + + $this->_pass = $pass; + } + else if ($value instanceof self) + { + $this->_pass = $value->_pass; + } + else if (is_array($value) && isset($value[$property])) + { + $this->pass = $value[$property]; // resend value through __set('pass') for validation! + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid password provided; expecting a string or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + return $value; + + case 'segments': // Similar to System.Uri.Segments Property in .NET Framework: https://msdn.microsoft.com/en-us/library/system.uri.segments.aspx + + // is_array() example; ['/', 'admin/', 'login'] + + if (is_array($value)) + { + $path = (string) implode($value); + + $this->_path = empty($path) ? null : $path; + } + else if ($value === null) + { + $this->_path = null; + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid segments provided; expecting an array or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + return $value; + + case 'dnssegments': + + // ['example', '.com'] => 'example.com' + + if (is_array($value)) + { + $host = (string) implode($value); + + $this->_host = empty($host) ? null : $host; + } + else if ($value === null) + { + $this->_host = null; + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid DNS segments; expecting an array or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + return $value; + + case 'authority': // compound property: [user[:pass]@]host[:port] + + if (is_string($value) && $value !== '') + { + $authority = &$value; + + if ($authority[0] === '/') + $authority = ltrim($authority, '/'); + + $split = explode('@', $authority); + $count = count($split); + + if ($count === 1) + { + $authUser = null; + $authHost = &$split[0]; + } + else if ($count === 2) + { + $authUser = &$split[0]; + $authHost = &$split[1]; + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid authority; expecting a string with at most a single `@` sign or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + // detect if optional port exists + if (strpos($authHost, ':') === false) + { + $this->host = $authHost; // run through validation + $this->_port = null; + } + else + { + $pos = strpos($authHost, ':'); + $this->host = substr($authHost, 0, $pos); + $this->port = substr($authHost, $pos + 1); + } + + if ($authUser === null) + { + $this->_user = null; + $this->_pass = null; + } + else + { + if (strpos($authUser, ':') === false) + { + $this->user = $authUser; // run through validation + $this->_pass = null; + } + else + { + $pos = strpos($authUser, ':'); + $this->user = substr($authUser, 0, $pos); + $this->pass = substr($authUser, $pos + 1); + } + } + } + else if ($value === null || $value === '') + { + $this->_user = null; + $this->_pass = null; + $this->_host = null; + $this->_port = null; + } + else if ($value instanceof self) + { + $this->_user = $value->_user; + $this->_pass = $value->_pass; + $this->_host = $value->_host; + $this->_port = $value->_port; + } + else if (is_array($value)) + { // resend all values through __set('...') for validation! + $this->user = $value['user'] ?? null; + $this->pass = $value['pass'] ?? null; + $this->host = $value['host'] ?? null; + $this->port = $value['port'] ?? null; + } + else + { + throw new InvalidArgumentException(sprintf( + 'Invalid authority provided; expecting a string or null; received %s', + (is_object($value) ? get_class($value) : gettype($value)) + )); + } + + return $value; + + default: + // if (in_array($name, hash_algos())) + // return hash($name, (string) $this); + + $property = &$name; + + if ( ! ctype_lower($property)) + $property = strtolower($property); + + if (self::$hashAlgos === null) + self::$hashAlgos = array_flip(hash_algos()); + + if (isset(self::$hashAlgos[$property])) + { + throw new InvalidArgumentException(sprintf( + 'Invalid Uri property "%s"; hash properties are read-only', + $name + )); + } + + //--- Start of alias and mixed-case properties ---// + + switch ($property) + { + // possible mixed-case variants `normalized` to lowercase. eg. `Scheme` => `scheme` + case 'scheme': return $this->scheme = $value; + case 'host': return $this->host = $value; + case 'path': return $this->path = $value; + case 'query': return $this->query = $value; + + case 'user': return $this->user = $value; + case 'pass': return $this->pass = $value; + case 'port': return $this->port = $value; + case 'fragment': return $this->fragment = $value; + + case 'realport': // My alias of .NET `Uri.StrongPort` + case 'strongport': + + // This is called the Uri.StrongPort property in .NET + // `The Port data. If no port data is in the Uri and a default port has been assigned to the Scheme, + // the default port is returned. If there is no default port, -1 is returned.` + + return $this->port ?? self::$allowedSchemes[$this->scheme] ?? -1; + + case 'userinfo': return $this->userInfo; // mixed-case variant + case 'segments': return $this->segments; + case 'authority': return $this->authority; + case 'querystring': return $this->query; // possible mixed-case variant of `queryString` + case 'username': return $this->user; + case 'password': return $this->pass; + case 'uri': return (string) $this; + case 'tostring': return (string) $this; + } + + + + } + + $trace = debug_backtrace(); + trigger_error('Undefined property: ' . __CLASS__ . "->{$name} in {$trace[0]['file']} on line {$trace[0]['line']}; thrown", E_USER_ERROR); + } + +} diff --git a/src/Validation.php b/src/Validation.php index a9f2e49..8a3c10c 100644 --- a/src/Validation.php +++ b/src/Validation.php @@ -2,7 +2,7 @@ namespace Twister; -class Validation +class Validation // AKA Filter { - + // TODO }