diff options
author | Frederic Guillot <fred@kanboard.net> | 2015-10-25 18:11:49 -0400 |
---|---|---|
committer | Frederic Guillot <fred@kanboard.net> | 2015-10-25 18:11:49 -0400 |
commit | a2ebc6c3b2ec3e420a03e36faf00c6e3bf3f25e7 (patch) | |
tree | 55bbe24de52dc14591033cb3f9265acfa74cdbb8 /app/Core/Http | |
parent | 6756ef2301a5f624941b947ec9effd34b467de9a (diff) |
Move some classes to namespace Core\Http
Diffstat (limited to 'app/Core/Http')
-rw-r--r-- | app/Core/Http/Client.php | 163 | ||||
-rw-r--r-- | app/Core/Http/Request.php | 243 | ||||
-rw-r--r-- | app/Core/Http/Response.php | 273 | ||||
-rw-r--r-- | app/Core/Http/Router.php | 230 |
4 files changed, 909 insertions, 0 deletions
diff --git a/app/Core/Http/Client.php b/app/Core/Http/Client.php new file mode 100644 index 00000000..c6bf36a6 --- /dev/null +++ b/app/Core/Http/Client.php @@ -0,0 +1,163 @@ +<?php + +namespace Kanboard\Core\Http; + +use Kanboard\Core\Base; + +/** + * HTTP client + * + * @package http + * @author Frederic Guillot + */ +class Client extends Base +{ + /** + * HTTP connection timeout in seconds + * + * @var integer + */ + const HTTP_TIMEOUT = 5; + + /** + * Number of maximum redirections for the HTTP client + * + * @var integer + */ + const HTTP_MAX_REDIRECTS = 2; + + /** + * HTTP client user agent + * + * @var string + */ + const HTTP_USER_AGENT = 'Kanboard'; + + /** + * Send a GET HTTP request and parse JSON response + * + * @access public + * @param string $url + * @param string[] $headers + * @return array + */ + public function getJson($url, array $headers = array()) + { + $response = $this->doRequest('GET', $url, '', array_merge(array('Accept: application/json'), $headers)); + return json_decode($response, true) ?: array(); + } + + /** + * Send a POST HTTP request encoded in JSON + * + * @access public + * @param string $url + * @param array $data + * @param string[] $headers + * @return string + */ + public function postJson($url, array $data, array $headers = array()) + { + return $this->doRequest( + 'POST', + $url, + json_encode($data), + array_merge(array('Content-type: application/json'), $headers) + ); + } + + /** + * Send a POST HTTP request encoded in www-form-urlencoded + * + * @access public + * @param string $url + * @param array $data + * @param string[] $headers + * @return string + */ + public function postForm($url, array $data, array $headers = array()) + { + return $this->doRequest( + 'POST', + $url, + http_build_query($data), + array_merge(array('Content-type: application/x-www-form-urlencoded'), $headers) + ); + } + + /** + * Make the HTTP request + * + * @access private + * @param string $method + * @param string $url + * @param string $content + * @param string[] $headers + * @return string + */ + private function doRequest($method, $url, $content, array $headers) + { + if (empty($url)) { + return ''; + } + + $stream = @fopen(trim($url), 'r', false, stream_context_create($this->getContext($method, $content, $headers))); + $response = ''; + + if (is_resource($stream)) { + $response = stream_get_contents($stream); + } else { + $this->logger->error('HttpClient: request failed'); + } + + if (DEBUG) { + $this->logger->debug('HttpClient: url='.$url); + $this->logger->debug('HttpClient: payload='.$content); + $this->logger->debug('HttpClient: metadata='.var_export(@stream_get_meta_data($stream), true)); + $this->logger->debug('HttpClient: response='.$response); + } + + return $response; + } + + /** + * Get stream context + * + * @access private + * @param string $method + * @param string $content + * @param string[] $headers + * @return array + */ + private function getContext($method, $content, array $headers) + { + $default_headers = array( + 'User-Agent: '.self::HTTP_USER_AGENT, + 'Connection: close', + ); + + if (HTTP_PROXY_USERNAME) { + $default_headers[] = 'Proxy-Authorization: Basic '.base64_encode(HTTP_PROXY_USERNAME.':'.HTTP_PROXY_PASSWORD); + } + + $headers = array_merge($default_headers, $headers); + + $context = array( + 'http' => array( + 'method' => $method, + 'protocol_version' => 1.1, + 'timeout' => self::HTTP_TIMEOUT, + 'max_redirects' => self::HTTP_MAX_REDIRECTS, + 'header' => implode("\r\n", $headers), + 'content' => $content + ) + ); + + if (HTTP_PROXY_HOSTNAME) { + $context['http']['proxy'] = 'tcp://'.HTTP_PROXY_HOSTNAME.':'.HTTP_PROXY_PORT; + $context['http']['request_fulluri'] = true; + } + + return $context; + } +} diff --git a/app/Core/Http/Request.php b/app/Core/Http/Request.php new file mode 100644 index 00000000..9f89a6e2 --- /dev/null +++ b/app/Core/Http/Request.php @@ -0,0 +1,243 @@ +<?php + +namespace Kanboard\Core\Http; + +use Kanboard\Core\Base; + +/** + * Request class + * + * @package http + * @author Frederic Guillot + */ +class Request extends Base +{ + /** + * Get URL string parameter + * + * @access public + * @param string $name Parameter name + * @param string $default_value Default value + * @return string + */ + public function getStringParam($name, $default_value = '') + { + return isset($_GET[$name]) ? $_GET[$name] : $default_value; + } + + /** + * Get URL integer parameter + * + * @access public + * @param string $name Parameter name + * @param integer $default_value Default value + * @return integer + */ + public function getIntegerParam($name, $default_value = 0) + { + return isset($_GET[$name]) && ctype_digit($_GET[$name]) ? (int) $_GET[$name] : $default_value; + } + + /** + * Get a form value + * + * @access public + * @param string $name Form field name + * @return string|null + */ + public function getValue($name) + { + $values = $this->getValues(); + return isset($values[$name]) ? $values[$name] : null; + } + + /** + * Get form values and check for CSRF token + * + * @access public + * @return array + */ + public function getValues() + { + if (! empty($_POST) && ! empty($_POST['csrf_token']) && $this->token->validateCSRFToken($_POST['csrf_token'])) { + unset($_POST['csrf_token']); + return $_POST; + } + + return array(); + } + + /** + * Get the raw body of the HTTP request + * + * @access public + * @return string + */ + public function getBody() + { + return file_get_contents('php://input'); + } + + /** + * Get the Json request body + * + * @access public + * @return array + */ + public function getJson() + { + return json_decode($this->getBody(), true) ?: array(); + } + + /** + * Get the content of an uploaded file + * + * @access public + * @param string $name Form file name + * @return string + */ + public function getFileContent($name) + { + if (isset($_FILES[$name])) { + return file_get_contents($_FILES[$name]['tmp_name']); + } + + return ''; + } + + /** + * Get the path of an uploaded file + * + * @access public + * @param string $name Form file name + * @return string + */ + public function getFilePath($name) + { + return isset($_FILES[$name]['tmp_name']) ? $_FILES[$name]['tmp_name'] : ''; + } + + /** + * Return true if the HTTP request is sent with the POST method + * + * @access public + * @return bool + */ + public function isPost() + { + return isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST'; + } + + /** + * Return true if the HTTP request is an Ajax request + * + * @access public + * @return bool + */ + public function isAjax() + { + return $this->getHeader('X-Requested-With') === 'XMLHttpRequest'; + } + + /** + * Check if the page is requested through HTTPS + * + * Note: IIS return the value 'off' and other web servers an empty value when it's not HTTPS + * + * @static + * @access public + * @return boolean + */ + public static function isHTTPS() + { + return isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== '' && $_SERVER['HTTPS'] !== 'off'; + } + + /** + * Return a HTTP header value + * + * @access public + * @param string $name Header name + * @return string + */ + public function getHeader($name) + { + $name = 'HTTP_'.str_replace('-', '_', strtoupper($name)); + return isset($_SERVER[$name]) ? $_SERVER[$name] : ''; + } + + /** + * Returns current request's query string, useful for redirecting + * + * @access public + * @return string + */ + public function getQueryString() + { + return isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : ''; + } + + /** + * Returns uri + * + * @access public + * @return string + */ + public function getUri() + { + return isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''; + } + + /** + * Get the user agent + * + * @static + * @access public + * @return string + */ + public static function getUserAgent() + { + return empty($_SERVER['HTTP_USER_AGENT']) ? t('Unknown') : $_SERVER['HTTP_USER_AGENT']; + } + + /** + * Get the real IP address of the user + * + * @static + * @access public + * @param bool $only_public Return only public IP address + * @return string + */ + public static function getIpAddress($only_public = false) + { + $keys = array( + 'HTTP_CLIENT_IP', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_FORWARDED', + 'HTTP_X_CLUSTER_CLIENT_IP', + 'HTTP_FORWARDED_FOR', + 'HTTP_FORWARDED', + 'REMOTE_ADDR' + ); + + foreach ($keys as $key) { + if (isset($_SERVER[$key])) { + foreach (explode(',', $_SERVER[$key]) as $ip_address) { + $ip_address = trim($ip_address); + + if ($only_public) { + + // Return only public IP address + if (filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) { + return $ip_address; + } + } else { + return $ip_address; + } + } + } + } + + return t('Unknown'); + } +} diff --git a/app/Core/Http/Response.php b/app/Core/Http/Response.php new file mode 100644 index 00000000..a793e58b --- /dev/null +++ b/app/Core/Http/Response.php @@ -0,0 +1,273 @@ +<?php + +namespace Kanboard\Core\Http; + +use Kanboard\Core\Base; + +/** + * Response class + * + * @package http + * @author Frederic Guillot + */ +class Response extends Base +{ + /** + * Send no cache headers + * + * @access public + */ + public function nocache() + { + header('Pragma: no-cache'); + header('Expires: Sat, 26 Jul 1997 05:00:00 GMT'); + + // Use no-store due to a Chrome bug: https://code.google.com/p/chromium/issues/detail?id=28035 + header('Cache-Control: no-store, must-revalidate'); + } + + /** + * Send a custom Content-Type header + * + * @access public + * @param string $mimetype Mime-type + */ + public function contentType($mimetype) + { + header('Content-Type: '.$mimetype); + } + + /** + * Force the browser to download an attachment + * + * @access public + * @param string $filename File name + */ + public function forceDownload($filename) + { + header('Content-Disposition: attachment; filename="'.$filename.'"'); + } + + /** + * Send a custom HTTP status code + * + * @access public + * @param integer $status_code HTTP status code + */ + public function status($status_code) + { + header('Status: '.$status_code); + header($_SERVER['SERVER_PROTOCOL'].' '.$status_code); + } + + /** + * Redirect to another URL + * + * @access public + * @param string $url Redirection URL + */ + public function redirect($url) + { + if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') { + header('X-Ajax-Redirect: '.$url); + } else { + header('Location: '.$url); + } + + exit; + } + + /** + * Send a CSV response + * + * @access public + * @param array $data Data to serialize in csv + * @param integer $status_code HTTP status code + */ + public function csv(array $data, $status_code = 200) + { + $this->status($status_code); + $this->nocache(); + + header('Content-Type: text/csv'); + Csv::output($data); + exit; + } + + /** + * Send a Json response + * + * @access public + * @param array $data Data to serialize in json + * @param integer $status_code HTTP status code + */ + public function json(array $data, $status_code = 200) + { + $this->status($status_code); + $this->nocache(); + header('Content-Type: application/json'); + echo json_encode($data); + exit; + } + + /** + * Send a text response + * + * @access public + * @param string $data Raw data + * @param integer $status_code HTTP status code + */ + public function text($data, $status_code = 200) + { + $this->status($status_code); + $this->nocache(); + header('Content-Type: text/plain; charset=utf-8'); + echo $data; + exit; + } + + /** + * Send a HTML response + * + * @access public + * @param string $data Raw data + * @param integer $status_code HTTP status code + */ + public function html($data, $status_code = 200) + { + $this->status($status_code); + $this->nocache(); + header('Content-Type: text/html; charset=utf-8'); + echo $data; + exit; + } + + /** + * Send a XML response + * + * @access public + * @param string $data Raw data + * @param integer $status_code HTTP status code + */ + public function xml($data, $status_code = 200) + { + $this->status($status_code); + $this->nocache(); + header('Content-Type: text/xml; charset=utf-8'); + echo $data; + exit; + } + + /** + * Send a javascript response + * + * @access public + * @param string $data Raw data + * @param integer $status_code HTTP status code + */ + public function js($data, $status_code = 200) + { + $this->status($status_code); + + header('Content-Type: text/javascript; charset=utf-8'); + echo $data; + + exit; + } + + /** + * Send a css response + * + * @access public + * @param string $data Raw data + * @param integer $status_code HTTP status code + */ + public function css($data, $status_code = 200) + { + $this->status($status_code); + + header('Content-Type: text/css; charset=utf-8'); + echo $data; + + exit; + } + + /** + * Send a binary response + * + * @access public + * @param string $data Raw data + * @param integer $status_code HTTP status code + */ + public function binary($data, $status_code = 200) + { + $this->status($status_code); + $this->nocache(); + header('Content-Transfer-Encoding: binary'); + header('Content-Type: application/octet-stream'); + echo $data; + exit; + } + + /** + * Send the security header: Content-Security-Policy + * + * @access public + * @param array $policies CSP rules + */ + public function csp(array $policies = array()) + { + $policies['default-src'] = "'self'"; + $values = ''; + + foreach ($policies as $policy => $acl) { + $values .= $policy.' '.trim($acl).'; '; + } + + header('Content-Security-Policy: '.$values); + } + + /** + * Send the security header: X-Content-Type-Options + * + * @access public + */ + public function nosniff() + { + header('X-Content-Type-Options: nosniff'); + } + + /** + * Send the security header: X-XSS-Protection + * + * @access public + */ + public function xss() + { + header('X-XSS-Protection: 1; mode=block'); + } + + /** + * Send the security header: Strict-Transport-Security (only if we use HTTPS) + * + * @access public + */ + public function hsts() + { + if (Request::isHTTPS()) { + header('Strict-Transport-Security: max-age=31536000'); + } + } + + /** + * Send the security header: X-Frame-Options (deny by default) + * + * @access public + * @param string $mode Frame option mode + * @param array $urls Allowed urls for the given mode + */ + public function xframe($mode = 'DENY', array $urls = array()) + { + header('X-Frame-Options: '.$mode.' '.implode(' ', $urls)); + } +} diff --git a/app/Core/Http/Router.php b/app/Core/Http/Router.php new file mode 100644 index 00000000..0080b23a --- /dev/null +++ b/app/Core/Http/Router.php @@ -0,0 +1,230 @@ +<?php + +namespace Kanboard\Core\Http; + +use RuntimeException; +use Kanboard\Core\Base; + +/** + * Router class + * + * @package http + * @author Frederic Guillot + */ +class Router extends Base +{ + /** + * Controller + * + * @access private + * @var string + */ + private $controller = ''; + + /** + * Action + * + * @access private + * @var string + */ + private $action = ''; + + /** + * Store routes for path lookup + * + * @access private + * @var array + */ + private $paths = array(); + + /** + * Store routes for url lookup + * + * @access private + * @var array + */ + private $urls = array(); + + /** + * Get action + * + * @access public + * @return string + */ + public function getAction() + { + return $this->action; + } + + /** + * Get controller + * + * @access public + * @return string + */ + public function getController() + { + return $this->controller; + } + + /** + * Get the path to compare patterns + * + * @access public + * @param string $uri + * @param string $query_string + * @return string + */ + public function getPath($uri, $query_string = '') + { + $path = substr($uri, strlen($this->helper->url->dir())); + + if (! empty($query_string)) { + $path = substr($path, 0, - strlen($query_string) - 1); + } + + if (! empty($path) && $path{0} === '/') { + $path = substr($path, 1); + } + + return $path; + } + + /** + * Add route + * + * @access public + * @param string $path + * @param string $controller + * @param string $action + * @param array $params + */ + public function addRoute($path, $controller, $action, array $params = array()) + { + $pattern = explode('/', $path); + + $this->paths[] = array( + 'pattern' => $pattern, + 'count' => count($pattern), + 'controller' => $controller, + 'action' => $action, + ); + + $this->urls[$controller][$action][] = array( + 'path' => $path, + 'params' => array_flip($params), + 'count' => count($params), + ); + } + + /** + * Find a route according to the given path + * + * @access public + * @param string $path + * @return array + */ + public function findRoute($path) + { + $parts = explode('/', $path); + $count = count($parts); + + foreach ($this->paths as $route) { + if ($count === $route['count']) { + $params = array(); + + for ($i = 0; $i < $count; $i++) { + if ($route['pattern'][$i]{0} === ':') { + $params[substr($route['pattern'][$i], 1)] = $parts[$i]; + } elseif ($route['pattern'][$i] !== $parts[$i]) { + break; + } + } + + if ($i === $count) { + $_GET = array_merge($_GET, $params); + return array($route['controller'], $route['action']); + } + } + } + + return array('app', 'index'); + } + + /** + * Find route url + * + * @access public + * @param string $controller + * @param string $action + * @param array $params + * @return string + */ + public function findUrl($controller, $action, array $params = array()) + { + if (! isset($this->urls[$controller][$action])) { + return ''; + } + + foreach ($this->urls[$controller][$action] as $pattern) { + if (array_diff_key($params, $pattern['params']) === array()) { + $url = $pattern['path']; + $i = 0; + + foreach ($params as $variable => $value) { + $url = str_replace(':'.$variable, $value, $url); + $i++; + } + + if ($i === $pattern['count']) { + return $url; + } + } + } + + return ''; + } + + /** + * Check controller and action parameter + * + * @access public + * @param string $value Controller or action name + * @param string $default_value Default value if validation fail + * @return string + */ + public function sanitize($value, $default_value) + { + return ! preg_match('/^[a-zA-Z_0-9]+$/', $value) ? $default_value : $value; + } + + /** + * Find controller/action from the route table or from get arguments + * + * @access public + * @param string $uri + * @param string $query_string + */ + public function dispatch($uri, $query_string = '') + { + if (! empty($_GET['controller']) && ! empty($_GET['action'])) { + $this->controller = $this->sanitize($_GET['controller'], 'app'); + $this->action = $this->sanitize($_GET['action'], 'index'); + $plugin = ! empty($_GET['plugin']) ? $this->sanitize($_GET['plugin'], '') : ''; + } else { + list($this->controller, $this->action) = $this->findRoute($this->getPath($uri, $query_string)); // TODO: add plugin for routes + $plugin = ''; + } + + $class = '\Kanboard\\'; + $class .= empty($plugin) ? 'Controller\\'.ucfirst($this->controller) : 'Plugin\\'.ucfirst($plugin).'\Controller\\'.ucfirst($this->controller); + + if (! class_exists($class) || ! method_exists($class, $this->action)) { + throw new RuntimeException('Controller or method not found for the given url!'); + } + + $instance = new $class($this->container); + $instance->beforeAction($this->controller, $this->action); + $instance->{$this->action}(); + } +} |