summaryrefslogtreecommitdiff
path: root/app/Core/Http
diff options
context:
space:
mode:
Diffstat (limited to 'app/Core/Http')
-rw-r--r--app/Core/Http/Client.php176
-rw-r--r--app/Core/Http/OAuth2.php121
-rw-r--r--app/Core/Http/RememberMeCookie.php120
-rw-r--r--app/Core/Http/Request.php337
-rw-r--r--app/Core/Http/Response.php276
-rw-r--r--app/Core/Http/Route.php187
-rw-r--r--app/Core/Http/Router.php169
7 files changed, 1386 insertions, 0 deletions
diff --git a/app/Core/Http/Client.php b/app/Core/Http/Client.php
new file mode 100644
index 00000000..12b0a1cb
--- /dev/null
+++ b/app/Core/Http/Client.php
@@ -0,0 +1,176 @@
+<?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
+ *
+ * @access public
+ * @param string $url
+ * @param string[] $headers
+ * @return string
+ */
+ public function get($url, array $headers = array())
+ {
+ return $this->doRequest('GET', $url, '', $headers);
+ }
+
+ /**
+ * 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/OAuth2.php b/app/Core/Http/OAuth2.php
new file mode 100644
index 00000000..6fa1fb0a
--- /dev/null
+++ b/app/Core/Http/OAuth2.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+use Kanboard\Core\Base;
+
+/**
+ * OAuth2 Client
+ *
+ * @package http
+ * @author Frederic Guillot
+ */
+class OAuth2 extends Base
+{
+ private $clientId;
+ private $secret;
+ private $callbackUrl;
+ private $authUrl;
+ private $tokenUrl;
+ private $scopes;
+ private $tokenType;
+ private $accessToken;
+
+ /**
+ * Create OAuth2 service
+ *
+ * @access public
+ * @param string $clientId
+ * @param string $secret
+ * @param string $callbackUrl
+ * @param string $authUrl
+ * @param string $tokenUrl
+ * @param array $scopes
+ * @return OAuth2
+ */
+ public function createService($clientId, $secret, $callbackUrl, $authUrl, $tokenUrl, array $scopes)
+ {
+ $this->clientId = $clientId;
+ $this->secret = $secret;
+ $this->callbackUrl = $callbackUrl;
+ $this->authUrl = $authUrl;
+ $this->tokenUrl = $tokenUrl;
+ $this->scopes = $scopes;
+
+ return $this;
+ }
+
+ /**
+ * Get authorization url
+ *
+ * @access public
+ * @return string
+ */
+ public function getAuthorizationUrl()
+ {
+ $params = array(
+ 'response_type' => 'code',
+ 'client_id' => $this->clientId,
+ 'redirect_uri' => $this->callbackUrl,
+ 'scope' => implode(' ', $this->scopes),
+ );
+
+ return $this->authUrl.'?'.http_build_query($params);
+ }
+
+ /**
+ * Get authorization header
+ *
+ * @access public
+ * @return string
+ */
+ public function getAuthorizationHeader()
+ {
+ if (strtolower($this->tokenType) === 'bearer') {
+ return 'Authorization: Bearer '.$this->accessToken;
+ }
+
+ return '';
+ }
+
+ /**
+ * Get access token
+ *
+ * @access public
+ * @param string $code
+ * @return string
+ */
+ public function getAccessToken($code)
+ {
+ if (empty($this->accessToken) && ! empty($code)) {
+ $params = array(
+ 'code' => $code,
+ 'client_id' => $this->clientId,
+ 'client_secret' => $this->secret,
+ 'redirect_uri' => $this->callbackUrl,
+ 'grant_type' => 'authorization_code',
+ );
+
+ $response = json_decode($this->httpClient->postForm($this->tokenUrl, $params, array('Accept: application/json')), true);
+
+ $this->tokenType = isset($response['token_type']) ? $response['token_type'] : '';
+ $this->accessToken = isset($response['access_token']) ? $response['access_token'] : '';
+ }
+
+ return $this->accessToken;
+ }
+
+ /**
+ * Set access token
+ *
+ * @access public
+ * @param string $token
+ * @param string $type
+ * @return string
+ */
+ public function setAccessToken($token, $type = 'bearer')
+ {
+ $this->accessToken = $token;
+ $this->tokenType = $type;
+ }
+}
diff --git a/app/Core/Http/RememberMeCookie.php b/app/Core/Http/RememberMeCookie.php
new file mode 100644
index 00000000..a32b35f3
--- /dev/null
+++ b/app/Core/Http/RememberMeCookie.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+use Kanboard\Core\Base;
+
+/**
+ * Remember Me Cookie
+ *
+ * @package http
+ * @author Frederic Guillot
+ */
+class RememberMeCookie extends Base
+{
+ /**
+ * Cookie name
+ *
+ * @var string
+ */
+ const COOKIE_NAME = 'KB_RM';
+
+ /**
+ * Encode the cookie
+ *
+ * @access public
+ * @param string $token Session token
+ * @param string $sequence Sequence token
+ * @return string
+ */
+ public function encode($token, $sequence)
+ {
+ return implode('|', array($token, $sequence));
+ }
+
+ /**
+ * Decode the value of a cookie
+ *
+ * @access public
+ * @param string $value Raw cookie data
+ * @return array
+ */
+ public function decode($value)
+ {
+ list($token, $sequence) = explode('|', $value);
+
+ return array(
+ 'token' => $token,
+ 'sequence' => $sequence,
+ );
+ }
+
+ /**
+ * Return true if the current user has a RememberMe cookie
+ *
+ * @access public
+ * @return bool
+ */
+ public function hasCookie()
+ {
+ return $this->request->getCookie(self::COOKIE_NAME) !== '';
+ }
+
+ /**
+ * Write and encode the cookie
+ *
+ * @access public
+ * @param string $token Session token
+ * @param string $sequence Sequence token
+ * @param string $expiration Cookie expiration
+ * @return boolean
+ */
+ public function write($token, $sequence, $expiration)
+ {
+ return setcookie(
+ self::COOKIE_NAME,
+ $this->encode($token, $sequence),
+ $expiration,
+ $this->helper->url->dir(),
+ null,
+ $this->request->isHTTPS(),
+ true
+ );
+ }
+
+ /**
+ * Read and decode the cookie
+ *
+ * @access public
+ * @return mixed
+ */
+ public function read()
+ {
+ $cookie = $this->request->getCookie(self::COOKIE_NAME);
+
+ if (empty($cookie)) {
+ return false;
+ }
+
+ return $this->decode($cookie);
+ }
+
+ /**
+ * Remove the cookie
+ *
+ * @access public
+ * @return boolean
+ */
+ public function remove()
+ {
+ return setcookie(
+ self::COOKIE_NAME,
+ '',
+ time() - 3600,
+ $this->helper->url->dir(),
+ null,
+ $this->request->isHTTPS(),
+ true
+ );
+ }
+}
diff --git a/app/Core/Http/Request.php b/app/Core/Http/Request.php
new file mode 100644
index 00000000..1b3036d5
--- /dev/null
+++ b/app/Core/Http/Request.php
@@ -0,0 +1,337 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+use Pimple\Container;
+use Kanboard\Core\Base;
+
+/**
+ * Request class
+ *
+ * @package http
+ * @author Frederic Guillot
+ */
+class Request extends Base
+{
+ /**
+ * Pointer to PHP environment variables
+ *
+ * @access private
+ * @var array
+ */
+ private $server;
+ private $get;
+ private $post;
+ private $files;
+ private $cookies;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ */
+ public function __construct(Container $container, array $server = array(), array $get = array(), array $post = array(), array $files = array(), array $cookies = array())
+ {
+ parent::__construct($container);
+ $this->server = empty($server) ? $_SERVER : $server;
+ $this->get = empty($get) ? $_GET : $get;
+ $this->post = empty($post) ? $_POST : $post;
+ $this->files = empty($files) ? $_FILES : $files;
+ $this->cookies = empty($cookies) ? $_COOKIE : $cookies;
+ }
+
+ /**
+ * Set GET parameters
+ *
+ * @param array $params
+ */
+ public function setParams(array $params)
+ {
+ $this->get = array_merge($this->get, $params);
+ }
+
+ /**
+ * Get query string 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($this->get[$name]) ? $this->get[$name] : $default_value;
+ }
+
+ /**
+ * Get query string 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($this->get[$name]) && ctype_digit($this->get[$name]) ? (int) $this->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($this->post) && ! empty($this->post['csrf_token']) && $this->token->validateCSRFToken($this->post['csrf_token'])) {
+ unset($this->post['csrf_token']);
+ return $this->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($this->files[$name]['tmp_name'])) {
+ return file_get_contents($this->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($this->files[$name]['tmp_name']) ? $this->files[$name]['tmp_name'] : '';
+ }
+
+ /**
+ * Get info of an uploaded file
+ *
+ * @access public
+ * @param string $name Form file name
+ * @return array
+ */
+ public function getFileInfo($name)
+ {
+ return isset($this->files[$name]) ? $this->files[$name] : array();
+ }
+
+ /**
+ * Return HTTP method
+ *
+ * @access public
+ * @return bool
+ */
+ public function getMethod()
+ {
+ return $this->getServerVariable('REQUEST_METHOD');
+ }
+
+ /**
+ * Return true if the HTTP request is sent with the POST method
+ *
+ * @access public
+ * @return bool
+ */
+ public function isPost()
+ {
+ return $this->getServerVariable('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
+ *
+ * @access public
+ * @return boolean
+ */
+ public function isHTTPS()
+ {
+ return isset($this->server['HTTPS']) && $this->server['HTTPS'] !== '' && $this->server['HTTPS'] !== 'off';
+ }
+
+ /**
+ * Get cookie value
+ *
+ * @access public
+ * @param string $name
+ * @return string
+ */
+ public function getCookie($name)
+ {
+ return isset($this->cookies[$name]) ? $this->cookies[$name] : '';
+ }
+
+ /**
+ * 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 $this->getServerVariable($name);
+ }
+
+ /**
+ * Get remote user
+ *
+ * @access public
+ * @return string
+ */
+ public function getRemoteUser()
+ {
+ return $this->getServerVariable(REVERSE_PROXY_USER_HEADER);
+ }
+
+ /**
+ * Returns query string
+ *
+ * @access public
+ * @return string
+ */
+ public function getQueryString()
+ {
+ return $this->getServerVariable('QUERY_STRING');
+ }
+
+ /**
+ * Return URI
+ *
+ * @access public
+ * @return string
+ */
+ public function getUri()
+ {
+ return $this->getServerVariable('REQUEST_URI');
+ }
+
+ /**
+ * Get the user agent
+ *
+ * @access public
+ * @return string
+ */
+ public function getUserAgent()
+ {
+ return empty($this->server['HTTP_USER_AGENT']) ? t('Unknown') : $this->server['HTTP_USER_AGENT'];
+ }
+
+ /**
+ * Get the IP address of the user
+ *
+ * @access public
+ * @return string
+ */
+ public function getIpAddress()
+ {
+ $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 ($this->getServerVariable($key) !== '') {
+ foreach (explode(',', $this->server[$key]) as $ipAddress) {
+ return trim($ipAddress);
+ }
+ }
+ }
+
+ return t('Unknown');
+ }
+
+ /**
+ * Get start time
+ *
+ * @access public
+ * @return float
+ */
+ public function getStartTime()
+ {
+ return $this->getServerVariable('REQUEST_TIME_FLOAT') ?: 0;
+ }
+
+ /**
+ * Get server variable
+ *
+ * @access public
+ * @param string $variable
+ * @return string
+ */
+ public function getServerVariable($variable)
+ {
+ return isset($this->server[$variable]) ? $this->server[$variable] : '';
+ }
+}
diff --git a/app/Core/Http/Response.php b/app/Core/Http/Response.php
new file mode 100644
index 00000000..d098f519
--- /dev/null
+++ b/app/Core/Http/Response.php
@@ -0,0 +1,276 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+use Kanboard\Core\Base;
+use Kanboard\Core\Csv;
+
+/**
+ * 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.'"');
+ header('Content-Transfer-Encoding: binary');
+ header('Content-Type: application/octet-stream');
+ }
+
+ /**
+ * 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($this->request->getServerVariable('SERVER_PROTOCOL').' '.$status_code);
+ }
+
+ /**
+ * Redirect to another URL
+ *
+ * @access public
+ * @param string $url Redirection URL
+ * @param boolean $self If Ajax request and true: refresh the current page
+ */
+ public function redirect($url, $self = false)
+ {
+ if ($this->request->isAjax()) {
+ header('X-Ajax-Redirect: '.($self ? 'self' : $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())
+ {
+ $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 ($this->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/Route.php b/app/Core/Http/Route.php
new file mode 100644
index 00000000..7836146d
--- /dev/null
+++ b/app/Core/Http/Route.php
@@ -0,0 +1,187 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+use Kanboard\Core\Base;
+
+/**
+ * Route Handler
+ *
+ * @package http
+ * @author Frederic Guillot
+ */
+class Route extends Base
+{
+ /**
+ * Flag that enable the routing table
+ *
+ * @access private
+ * @var boolean
+ */
+ private $activated = false;
+
+ /**
+ * Store routes for path lookup
+ *
+ * @access private
+ * @var array
+ */
+ private $paths = array();
+
+ /**
+ * Store routes for url lookup
+ *
+ * @access private
+ * @var array
+ */
+ private $urls = array();
+
+ /**
+ * Enable routing table
+ *
+ * @access public
+ * @return Route
+ */
+ public function enable()
+ {
+ $this->activated = true;
+ return $this;
+ }
+
+ /**
+ * Add route
+ *
+ * @access public
+ * @param string $path
+ * @param string $controller
+ * @param string $action
+ * @param string $plugin
+ * @return Route
+ */
+ public function addRoute($path, $controller, $action, $plugin = '')
+ {
+ if ($this->activated) {
+ $path = ltrim($path, '/');
+ $items = explode('/', $path);
+ $params = $this->findParams($items);
+
+ $this->paths[] = array(
+ 'items' => $items,
+ 'count' => count($items),
+ 'controller' => $controller,
+ 'action' => $action,
+ 'plugin' => $plugin,
+ );
+
+ $this->urls[$plugin][$controller][$action][] = array(
+ 'path' => $path,
+ 'params' => $params,
+ 'count' => count($params),
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * Find a route according to the given path
+ *
+ * @access public
+ * @param string $path
+ * @return array
+ */
+ public function findRoute($path)
+ {
+ $items = explode('/', ltrim($path, '/'));
+ $count = count($items);
+
+ foreach ($this->paths as $route) {
+ if ($count === $route['count']) {
+ $params = array();
+
+ for ($i = 0; $i < $count; $i++) {
+ if ($route['items'][$i]{0} === ':') {
+ $params[substr($route['items'][$i], 1)] = $items[$i];
+ } elseif ($route['items'][$i] !== $items[$i]) {
+ break;
+ }
+ }
+
+ if ($i === $count) {
+ $this->request->setParams($params);
+ return array(
+ 'controller' => $route['controller'],
+ 'action' => $route['action'],
+ 'plugin' => $route['plugin'],
+ );
+ }
+ }
+ }
+
+ return array(
+ 'controller' => 'app',
+ 'action' => 'index',
+ 'plugin' => '',
+ );
+ }
+
+ /**
+ * Find route url
+ *
+ * @access public
+ * @param string $controller
+ * @param string $action
+ * @param array $params
+ * @param string $plugin
+ * @return string
+ */
+ public function findUrl($controller, $action, array $params = array(), $plugin = '')
+ {
+ if ($plugin === '' && isset($params['plugin'])) {
+ $plugin = $params['plugin'];
+ unset($params['plugin']);
+ }
+
+ if (! isset($this->urls[$plugin][$controller][$action])) {
+ return '';
+ }
+
+ foreach ($this->urls[$plugin][$controller][$action] as $route) {
+ if (array_diff_key($params, $route['params']) === array()) {
+ $url = $route['path'];
+ $i = 0;
+
+ foreach ($params as $variable => $value) {
+ $url = str_replace(':'.$variable, $value, $url);
+ $i++;
+ }
+
+ if ($i === $route['count']) {
+ return $url;
+ }
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Find url params
+ *
+ * @access public
+ * @param array $items
+ * @return array
+ */
+ public function findParams(array $items)
+ {
+ $params = array();
+
+ foreach ($items as $item) {
+ if ($item !== '' && $item{0} === ':') {
+ $params[substr($item, 1)] = true;
+ }
+ }
+
+ return $params;
+ }
+}
diff --git a/app/Core/Http/Router.php b/app/Core/Http/Router.php
new file mode 100644
index 00000000..0fe80ecc
--- /dev/null
+++ b/app/Core/Http/Router.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace Kanboard\Core\Http;
+
+use RuntimeException;
+use Kanboard\Core\Base;
+
+/**
+ * Route Dispatcher
+ *
+ * @package http
+ * @author Frederic Guillot
+ */
+class Router extends Base
+{
+ /**
+ * Plugin name
+ *
+ * @access private
+ * @var string
+ */
+ private $plugin = '';
+
+ /**
+ * Controller
+ *
+ * @access private
+ * @var string
+ */
+ private $controller = '';
+
+ /**
+ * Action
+ *
+ * @access private
+ * @var string
+ */
+ private $action = '';
+
+ /**
+ * Get plugin name
+ *
+ * @access public
+ * @return string
+ */
+ public function getPlugin()
+ {
+ return $this->plugin;
+ }
+
+ /**
+ * Get controller
+ *
+ * @access public
+ * @return string
+ */
+ public function getController()
+ {
+ return $this->controller;
+ }
+
+ /**
+ * Get action
+ *
+ * @access public
+ * @return string
+ */
+ public function getAction()
+ {
+ return $this->action;
+ }
+
+ /**
+ * Get the path to compare patterns
+ *
+ * @access public
+ * @return string
+ */
+ public function getPath()
+ {
+ $path = substr($this->request->getUri(), strlen($this->helper->url->dir()));
+
+ if ($this->request->getQueryString() !== '') {
+ $path = substr($path, 0, - strlen($this->request->getQueryString()) - 1);
+ }
+
+ if ($path !== '' && $path{0} === '/') {
+ $path = substr($path, 1);
+ }
+
+ return $path;
+ }
+
+ /**
+ * Find controller/action from the route table or from get arguments
+ *
+ * @access public
+ */
+ public function dispatch()
+ {
+ $controller = $this->request->getStringParam('controller');
+ $action = $this->request->getStringParam('action');
+ $plugin = $this->request->getStringParam('plugin');
+
+ if ($controller === '') {
+ $route = $this->route->findRoute($this->getPath());
+ $controller = $route['controller'];
+ $action = $route['action'];
+ $plugin = $route['plugin'];
+ }
+
+ $this->controller = ucfirst($this->sanitize($controller, 'app'));
+ $this->action = $this->sanitize($action, 'index');
+ $this->plugin = ucfirst($this->sanitize($plugin));
+
+ return $this->executeAction();
+ }
+
+ /**
+ * Check controller and action parameter
+ *
+ * @access public
+ * @param string $value
+ * @param string $default
+ * @return string
+ */
+ public function sanitize($value, $default = '')
+ {
+ return preg_match('/^[a-zA-Z_0-9]+$/', $value) ? $value : $default;
+ }
+
+ /**
+ * Execute controller action
+ *
+ * @access private
+ */
+ private function executeAction()
+ {
+ $class = $this->getControllerClassName();
+
+ if (! class_exists($class)) {
+ throw new RuntimeException('Controller not found');
+ }
+
+ if (! method_exists($class, $this->action)) {
+ throw new RuntimeException('Action not implemented');
+ }
+
+ $instance = new $class($this->container);
+ $instance->beforeAction();
+ $instance->{$this->action}();
+ return $instance;
+ }
+
+ /**
+ * Get controller class name
+ *
+ * @access private
+ * @return string
+ */
+ private function getControllerClassName()
+ {
+ if ($this->plugin !== '') {
+ return '\Kanboard\Plugin\\'.$this->plugin.'\Controller\\'.$this->controller;
+ }
+
+ return '\Kanboard\Controller\\'.$this->controller;
+ }
+}