From 6756ef2301a5f624941b947ec9effd34b467de9a Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 25 Oct 2015 15:05:19 -0400 Subject: Move token generation to Security namespace --- tests/units/Core/Security/TokenTest.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/units/Core/Security/TokenTest.php (limited to 'tests/units') diff --git a/tests/units/Core/Security/TokenTest.php b/tests/units/Core/Security/TokenTest.php new file mode 100644 index 00000000..dbb7bd1a --- /dev/null +++ b/tests/units/Core/Security/TokenTest.php @@ -0,0 +1,29 @@ +assertNotEmpty($t1); + $this->assertNotEmpty($t2); + + $this->assertNotEquals($t1, $t2); + } + + public function testCSRFTokens() + { + $token = new Token($this->container); + $t1 = $token->getCSRFToken(); + + $this->assertNotEmpty($t1); + $this->assertTrue($token->validateCSRFToken($t1)); + $this->assertFalse($token->validateCSRFToken($t1)); + } +} -- cgit v1.2.3 From a2ebc6c3b2ec3e420a03e36faf00c6e3bf3f25e7 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 25 Oct 2015 18:11:49 -0400 Subject: Move some classes to namespace Core\Http --- app/Auth/RememberMe.php | 2 +- app/Core/Base.php | 8 +- app/Core/Http/Client.php | 163 ++++++++++++++++++++ app/Core/Http/Request.php | 243 ++++++++++++++++++++++++++++++ app/Core/Http/Response.php | 273 ++++++++++++++++++++++++++++++++++ app/Core/Http/Router.php | 230 ++++++++++++++++++++++++++++ app/Core/HttpClient.php | 147 ------------------ app/Core/Request.php | 241 ------------------------------ app/Core/Response.php | 271 --------------------------------- app/Core/Router.php | 229 ---------------------------- app/Core/Session.php | 1 + app/Helper/Url.php | 2 +- app/Model/Authentication.php | 2 +- app/ServiceProvider/ClassProvider.php | 12 +- app/Subscriber/AuthSubscriber.php | 2 +- tests/units/Core/Http/RouterTest.php | 81 ++++++++++ tests/units/Core/RouterTest.php | 81 ---------- 17 files changed, 1009 insertions(+), 979 deletions(-) create mode 100644 app/Core/Http/Client.php create mode 100644 app/Core/Http/Request.php create mode 100644 app/Core/Http/Response.php create mode 100644 app/Core/Http/Router.php delete mode 100644 app/Core/HttpClient.php delete mode 100644 app/Core/Request.php delete mode 100644 app/Core/Response.php delete mode 100644 app/Core/Router.php create mode 100644 tests/units/Core/Http/RouterTest.php delete mode 100644 tests/units/Core/RouterTest.php (limited to 'tests/units') diff --git a/app/Auth/RememberMe.php b/app/Auth/RememberMe.php index 24f30a2c..fd8ed8bb 100644 --- a/app/Auth/RememberMe.php +++ b/app/Auth/RememberMe.php @@ -3,7 +3,7 @@ namespace Kanboard\Auth; use Kanboard\Core\Base; -use Kanboard\Core\Request; +use Kanboard\Core\Http\Request; use Kanboard\Event\AuthEvent; use Kanboard\Core\Security\Token; diff --git a/app/Core/Base.php b/app/Core/Base.php index d402fb37..11f4e31b 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -12,18 +12,20 @@ use Pimple\Container; * * @property \Kanboard\Core\Helper $helper * @property \Kanboard\Core\Mail\Client $emailClient - * @property \Kanboard\Core\HttpClient $httpClient * @property \Kanboard\Core\Paginator $paginator - * @property \Kanboard\Core\Request $request + * @property \Kanboard\Core\Http\Client $httpClient + * @property \Kanboard\Core\Http\Request $request + * @property \Kanboard\Core\Http\Router $router + * @property \Kanboard\Core\Http\Response $response * @property \Kanboard\Core\Session $session * @property \Kanboard\Core\Template $template * @property \Kanboard\Core\OAuth2 $oauth - * @property \Kanboard\Core\Router $router * @property \Kanboard\Core\Lexer $lexer * @property \Kanboard\Core\ObjectStorage\ObjectStorageInterface $objectStorage * @property \Kanboard\Core\Cache\Cache $memoryCache * @property \Kanboard\Core\Plugin\Hook $hook * @property \Kanboard\Core\Plugin\Loader $pluginLoader + * @property \Kanboard\Core\Security\Token $token * @property \Kanboard\Integration\BitbucketWebhook $bitbucketWebhook * @property \Kanboard\Integration\GithubWebhook $githubWebhook * @property \Kanboard\Integration\GitlabWebhook $gitlabWebhook 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 @@ +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 @@ +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 @@ +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 @@ +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}(); + } +} diff --git a/app/Core/HttpClient.php b/app/Core/HttpClient.php deleted file mode 100644 index 7f4ea47a..00000000 --- a/app/Core/HttpClient.php +++ /dev/null @@ -1,147 +0,0 @@ -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 ''; - } - - $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; - } - - $stream = @fopen(trim($url), 'r', false, stream_context_create($context)); - $response = ''; - - if (is_resource($stream)) { - $response = stream_get_contents($stream); - } else { - $this->container['logger']->error('HttpClient: request failed'); - } - - if (DEBUG) { - $this->container['logger']->debug('HttpClient: url='.$url); - $this->container['logger']->debug('HttpClient: payload='.$content); - $this->container['logger']->debug('HttpClient: metadata='.var_export(@stream_get_meta_data($stream), true)); - $this->container['logger']->debug('HttpClient: response='.$response); - } - - return $response; - } -} diff --git a/app/Core/Request.php b/app/Core/Request.php deleted file mode 100644 index 0398760e..00000000 --- a/app/Core/Request.php +++ /dev/null @@ -1,241 +0,0 @@ -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/Response.php b/app/Core/Response.php deleted file mode 100644 index 6788473a..00000000 --- a/app/Core/Response.php +++ /dev/null @@ -1,271 +0,0 @@ -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/Router.php b/app/Core/Router.php deleted file mode 100644 index 843f5139..00000000 --- a/app/Core/Router.php +++ /dev/null @@ -1,229 +0,0 @@ -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}(); - } -} diff --git a/app/Core/Session.php b/app/Core/Session.php index a93131c7..dd1e760e 100644 --- a/app/Core/Session.php +++ b/app/Core/Session.php @@ -3,6 +3,7 @@ namespace Kanboard\Core; use ArrayAccess; +use Kanboard\Core\Http\Request; /** * Session class diff --git a/app/Helper/Url.php b/app/Helper/Url.php index e47256ba..edb26841 100644 --- a/app/Helper/Url.php +++ b/app/Helper/Url.php @@ -2,7 +2,7 @@ namespace Kanboard\Helper; -use Kanboard\Core\Request; +use Kanboard\Core\Http\Request; use Kanboard\Core\Base; /** diff --git a/app/Model/Authentication.php b/app/Model/Authentication.php index 580c1e14..11e32313 100644 --- a/app/Model/Authentication.php +++ b/app/Model/Authentication.php @@ -2,7 +2,7 @@ namespace Kanboard\Model; -use Kanboard\Core\Request; +use Kanboard\Core\Http\Request; use SimpleValidator\Validator; use SimpleValidator\Validators; use Gregwar\Captcha\CaptchaBuilder; diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index c1a59f85..79bb734f 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -11,6 +11,7 @@ use Kanboard\Core\ObjectStorage\FileStorage; use Kanboard\Core\Paginator; use Kanboard\Core\OAuth2; use Kanboard\Core\Tool; +use Kanboard\Core\Http\Client as HttpClient; use Kanboard\Model\UserNotificationType; use Kanboard\Model\ProjectNotificationType; @@ -81,13 +82,14 @@ class ClassProvider implements ServiceProviderInterface 'Core' => array( 'DateParser', 'Helper', - 'HttpClient', 'Lexer', + 'Session', + 'Template', + ), + 'Core\Http' => array( 'Request', 'Response', 'Router', - 'Session', - 'Template', ), 'Core\Cache' => array( 'MemoryCache', @@ -117,6 +119,10 @@ class ClassProvider implements ServiceProviderInterface return new OAuth2($c); }); + $container['httpClient'] = function ($c) { + return new HttpClient($c); + }; + $container['htmlConverter'] = function () { return new HtmlConverter(array('strip_tags' => true)); }; diff --git a/app/Subscriber/AuthSubscriber.php b/app/Subscriber/AuthSubscriber.php index 2461b52c..77a39942 100644 --- a/app/Subscriber/AuthSubscriber.php +++ b/app/Subscriber/AuthSubscriber.php @@ -2,7 +2,7 @@ namespace Kanboard\Subscriber; -use Kanboard\Core\Request; +use Kanboard\Core\Http\Request; use Kanboard\Event\AuthEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; diff --git a/tests/units/Core/Http/RouterTest.php b/tests/units/Core/Http/RouterTest.php new file mode 100644 index 00000000..c2380247 --- /dev/null +++ b/tests/units/Core/Http/RouterTest.php @@ -0,0 +1,81 @@ +container); + + $this->assertEquals('PloP', $r->sanitize('PloP', 'default')); + $this->assertEquals('default', $r->sanitize('', 'default')); + $this->assertEquals('default', $r->sanitize('123-AB', 'default')); + $this->assertEquals('default', $r->sanitize('R&D', 'default')); + $this->assertEquals('Test123', $r->sanitize('Test123', 'default')); + $this->assertEquals('Test_123', $r->sanitize('Test_123', 'default')); + $this->assertEquals('userImport', $r->sanitize('userImport', 'default')); + } + + public function testPath() + { + $r = new Router($this->container); + + $this->assertEquals('a/b/c', $r->getPath('/a/b/c')); + $this->assertEquals('a/b/something', $r->getPath('/a/b/something?test=a', 'test=a')); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['PHP_SELF'] = '/a/index.php'; + + $this->assertEquals('b/c', $r->getPath('/a/b/c')); + $this->assertEquals('b/c', $r->getPath('/a/b/c?e=f', 'e=f')); + } + + public function testFindRouteWithEmptyTable() + { + $r = new Router($this->container); + $this->assertEquals(array('app', 'index'), $r->findRoute('')); + $this->assertEquals(array('app', 'index'), $r->findRoute('/')); + } + + public function testFindRouteWithoutPlaceholders() + { + $r = new Router($this->container); + $r->addRoute('a/b', 'controller', 'action'); + $this->assertEquals(array('app', 'index'), $r->findRoute('a/b/c')); + $this->assertEquals(array('controller', 'action'), $r->findRoute('a/b')); + } + + public function testFindRouteWithPlaceholders() + { + $r = new Router($this->container); + $r->addRoute('a/:myvar1/b/:myvar2', 'controller', 'action'); + $this->assertEquals(array('app', 'index'), $r->findRoute('a/123/b')); + $this->assertEquals(array('controller', 'action'), $r->findRoute('a/456/b/789')); + $this->assertEquals(array('myvar1' => 456, 'myvar2' => 789), $_GET); + } + + public function testFindMultipleRoutes() + { + $r = new Router($this->container); + $r->addRoute('a/b', 'controller1', 'action1'); + $r->addRoute('a/b', 'duplicate', 'duplicate'); + $r->addRoute('a', 'controller2', 'action2'); + $this->assertEquals(array('controller1', 'action1'), $r->findRoute('a/b')); + $this->assertEquals(array('controller2', 'action2'), $r->findRoute('a')); + } + + public function testFindUrl() + { + $r = new Router($this->container); + $r->addRoute('a/b', 'controller1', 'action1'); + $r->addRoute('a/:myvar1/b/:myvar2', 'controller2', 'action2', array('myvar1', 'myvar2')); + + $this->assertEquals('a/1/b/2', $r->findUrl('controller2', 'action2', array('myvar1' => 1, 'myvar2' => 2))); + $this->assertEquals('', $r->findUrl('controller2', 'action2', array('myvar1' => 1))); + $this->assertEquals('a/b', $r->findUrl('controller1', 'action1')); + $this->assertEquals('', $r->findUrl('controller1', 'action2')); + } +} diff --git a/tests/units/Core/RouterTest.php b/tests/units/Core/RouterTest.php deleted file mode 100644 index 753e1204..00000000 --- a/tests/units/Core/RouterTest.php +++ /dev/null @@ -1,81 +0,0 @@ -container); - - $this->assertEquals('PloP', $r->sanitize('PloP', 'default')); - $this->assertEquals('default', $r->sanitize('', 'default')); - $this->assertEquals('default', $r->sanitize('123-AB', 'default')); - $this->assertEquals('default', $r->sanitize('R&D', 'default')); - $this->assertEquals('Test123', $r->sanitize('Test123', 'default')); - $this->assertEquals('Test_123', $r->sanitize('Test_123', 'default')); - $this->assertEquals('userImport', $r->sanitize('userImport', 'default')); - } - - public function testPath() - { - $r = new Router($this->container); - - $this->assertEquals('a/b/c', $r->getPath('/a/b/c')); - $this->assertEquals('a/b/something', $r->getPath('/a/b/something?test=a', 'test=a')); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $_SERVER['PHP_SELF'] = '/a/index.php'; - - $this->assertEquals('b/c', $r->getPath('/a/b/c')); - $this->assertEquals('b/c', $r->getPath('/a/b/c?e=f', 'e=f')); - } - - public function testFindRouteWithEmptyTable() - { - $r = new Router($this->container); - $this->assertEquals(array('app', 'index'), $r->findRoute('')); - $this->assertEquals(array('app', 'index'), $r->findRoute('/')); - } - - public function testFindRouteWithoutPlaceholders() - { - $r = new Router($this->container); - $r->addRoute('a/b', 'controller', 'action'); - $this->assertEquals(array('app', 'index'), $r->findRoute('a/b/c')); - $this->assertEquals(array('controller', 'action'), $r->findRoute('a/b')); - } - - public function testFindRouteWithPlaceholders() - { - $r = new Router($this->container); - $r->addRoute('a/:myvar1/b/:myvar2', 'controller', 'action'); - $this->assertEquals(array('app', 'index'), $r->findRoute('a/123/b')); - $this->assertEquals(array('controller', 'action'), $r->findRoute('a/456/b/789')); - $this->assertEquals(array('myvar1' => 456, 'myvar2' => 789), $_GET); - } - - public function testFindMultipleRoutes() - { - $r = new Router($this->container); - $r->addRoute('a/b', 'controller1', 'action1'); - $r->addRoute('a/b', 'duplicate', 'duplicate'); - $r->addRoute('a', 'controller2', 'action2'); - $this->assertEquals(array('controller1', 'action1'), $r->findRoute('a/b')); - $this->assertEquals(array('controller2', 'action2'), $r->findRoute('a')); - } - - public function testFindUrl() - { - $r = new Router($this->container); - $r->addRoute('a/b', 'controller1', 'action1'); - $r->addRoute('a/:myvar1/b/:myvar2', 'controller2', 'action2', array('myvar1', 'myvar2')); - - $this->assertEquals('a/1/b/2', $r->findUrl('controller2', 'action2', array('myvar1' => 1, 'myvar2' => 2))); - $this->assertEquals('', $r->findUrl('controller2', 'action2', array('myvar1' => 1))); - $this->assertEquals('a/b', $r->findUrl('controller1', 'action1')); - $this->assertEquals('', $r->findUrl('controller1', 'action2')); - } -} -- cgit v1.2.3 From a675271ad71b7713d1b33bdba3c51b2b04813229 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 15 Nov 2015 12:50:33 -0500 Subject: Rewrite of session management --- ChangeLog | 5 + app/Api/Auth.php | 2 +- app/Api/Me.php | 2 +- app/Auth/Database.php | 2 +- app/Auth/Github.php | 2 +- app/Auth/Gitlab.php | 2 +- app/Auth/Google.php | 2 +- app/Auth/Ldap.php | 2 +- app/Auth/RememberMe.php | 4 +- app/Auth/ReverseProxy.php | 2 +- app/Controller/Action.php | 8 +- app/Controller/Auth.php | 12 +- app/Controller/Base.php | 9 +- app/Controller/Board.php | 8 +- app/Controller/Category.php | 14 +-- app/Controller/Column.php | 12 +- app/Controller/Comment.php | 12 +- app/Controller/Config.php | 8 +- app/Controller/Currency.php | 8 +- app/Controller/Customfilter.php | 12 +- app/Controller/File.php | 8 +- app/Controller/Gantt.php | 4 +- app/Controller/Link.php | 12 +- app/Controller/Oauth.php | 8 +- app/Controller/Project.php | 48 ++++---- app/Controller/Subtask.php | 14 +-- app/Controller/Swimlane.php | 26 ++-- app/Controller/Task.php | 4 +- app/Controller/TaskImport.php | 4 +- app/Controller/Taskcreation.php | 4 +- app/Controller/Taskduplication.php | 12 +- app/Controller/Tasklink.php | 12 +- app/Controller/Taskmodification.php | 16 +-- app/Controller/Taskstatus.php | 4 +- app/Controller/Twofactor.php | 14 +-- app/Controller/User.php | 28 ++--- app/Controller/UserImport.php | 4 +- app/Core/Base.php | 4 +- app/Core/Mail/Client.php | 2 +- app/Core/Security/Token.php | 10 +- app/Core/Session.php | 144 ----------------------- app/Core/Session/FlashMessage.php | 71 +++++++++++ app/Core/Session/SessionManager.php | 102 ++++++++++++++++ app/Core/Session/SessionStorage.php | 71 +++++++++++ app/Helper/App.php | 19 ++- app/Helper/Subtask.php | 2 +- app/Helper/User.php | 2 +- app/Model/Authentication.php | 10 +- app/Model/Config.php | 22 ++-- app/Model/User.php | 8 +- app/Model/UserSession.php | 70 +++++++---- app/ServiceProvider/ClassProvider.php | 3 +- app/ServiceProvider/DatabaseProvider.php | 2 + app/ServiceProvider/EventDispatcherProvider.php | 2 + app/ServiceProvider/LoggingProvider.php | 2 + app/ServiceProvider/SessionProvider.php | 29 +++++ app/common.php | 1 + tests/units/Action/TaskAssignCurrentUserTest.php | 7 +- tests/units/Auth/LdapTest.php | 16 +-- tests/units/Base.php | 8 ++ tests/units/Core/Session/FlashMessageTest.php | 23 ++++ tests/units/Core/Session/SessionStorageTest.php | 38 ++++++ tests/units/Helper/AppHelperTest.php | 10 +- tests/units/Helper/UserHelperTest.php | 19 +-- tests/units/Model/AclTest.php | 24 +--- tests/units/Model/ConfigTest.php | 10 +- tests/units/Model/SubtaskTest.php | 4 +- tests/units/Model/SubtaskTimeTrackingTest.php | 4 +- tests/units/Model/TaskCreationTest.php | 5 +- tests/units/Model/TaskDuplicationTest.php | 5 +- tests/units/Model/TaskPermissionTest.php | 16 +-- tests/units/Model/UserSessionTest.php | 144 +++++++++++++++++++++-- 72 files changed, 793 insertions(+), 466 deletions(-) delete mode 100644 app/Core/Session.php create mode 100644 app/Core/Session/FlashMessage.php create mode 100644 app/Core/Session/SessionManager.php create mode 100644 app/Core/Session/SessionStorage.php create mode 100644 app/ServiceProvider/SessionProvider.php create mode 100644 tests/units/Core/Session/FlashMessageTest.php create mode 100644 tests/units/Core/Session/SessionStorageTest.php (limited to 'tests/units') diff --git a/ChangeLog b/ChangeLog index 55417653..7d10c2c3 100644 --- a/ChangeLog +++ b/ChangeLog @@ -5,6 +5,11 @@ Improvements: * Improve error handling of plugins +Internal code refactoring: + +* Rewrite of session management +* Move some classes to a new namespace Kanboard\Core\Http + Bug fixes: * Loading cs_CZ locale display the wrong language in datetime picker diff --git a/app/Api/Auth.php b/app/Api/Auth.php index b3627e4b..a084d6eb 100644 --- a/app/Api/Auth.php +++ b/app/Api/Auth.php @@ -28,7 +28,7 @@ class Auth extends Base if ($username !== 'jsonrpc' && ! $this->authentication->hasCaptcha($username) && $this->authentication->authenticate($username, $password)) { $this->checkProcedurePermission(true, $method); - $this->userSession->refresh($this->user->getByUsername($username)); + $this->userSession->initialize($this->user->getByUsername($username)); } elseif ($username === 'jsonrpc' && $password === $this->config->get('api_token')) { $this->checkProcedurePermission(false, $method); } else { diff --git a/app/Api/Me.php b/app/Api/Me.php index 2c332a8c..2c4161fd 100644 --- a/app/Api/Me.php +++ b/app/Api/Me.php @@ -14,7 +14,7 @@ class Me extends Base { public function getMe() { - return $this->session['user']; + return $this->sessionStorage->user; } public function getMyDashboard() diff --git a/app/Auth/Database.php b/app/Auth/Database.php index 91b17a5f..c2041d4d 100644 --- a/app/Auth/Database.php +++ b/app/Auth/Database.php @@ -39,7 +39,7 @@ class Database extends Base ->findOne(); if (is_array($user) && password_verify($password, $user['password'])) { - $this->userSession->refresh($user); + $this->userSession->initialize($user); $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); return true; } diff --git a/app/Auth/Github.php b/app/Auth/Github.php index b89dc5b8..4777152a 100644 --- a/app/Auth/Github.php +++ b/app/Auth/Github.php @@ -39,7 +39,7 @@ class Github extends Base $user = $this->user->getByGithubId($github_id); if (! empty($user)) { - $this->userSession->refresh($user); + $this->userSession->initialize($user); $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); return true; } diff --git a/app/Auth/Gitlab.php b/app/Auth/Gitlab.php index a59bc1fa..698b59c3 100644 --- a/app/Auth/Gitlab.php +++ b/app/Auth/Gitlab.php @@ -39,7 +39,7 @@ class Gitlab extends Base $user = $this->user->getByGitlabId($gitlab_id); if (! empty($user)) { - $this->userSession->refresh($user); + $this->userSession->initialize($user); $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); return true; } diff --git a/app/Auth/Google.php b/app/Auth/Google.php index 32bcb4b1..6c1bc3cd 100644 --- a/app/Auth/Google.php +++ b/app/Auth/Google.php @@ -40,7 +40,7 @@ class Google extends Base $user = $this->user->getByGoogleId($google_id); if (! empty($user)) { - $this->userSession->refresh($user); + $this->userSession->initialize($user); $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); return true; } diff --git a/app/Auth/Ldap.php b/app/Auth/Ldap.php index c252be17..3d361aa7 100644 --- a/app/Auth/Ldap.php +++ b/app/Auth/Ldap.php @@ -237,7 +237,7 @@ class Ldap extends Base } // We open the session - $this->userSession->refresh($user); + $this->userSession->initialize($user); $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); return true; diff --git a/app/Auth/RememberMe.php b/app/Auth/RememberMe.php index fd8ed8bb..0a567cbe 100644 --- a/app/Auth/RememberMe.php +++ b/app/Auth/RememberMe.php @@ -101,10 +101,10 @@ class RememberMe extends Base ); // Create the session - $this->userSession->refresh($this->user->getById($record['user_id'])); + $this->userSession->initialize($this->user->getById($record['user_id'])); // Do not ask 2FA for remember me session - $this->session['2fa_validated'] = true; + $this->sessionStorage->postAuth['validated'] = true; $this->container['dispatcher']->dispatch( 'auth.success', diff --git a/app/Auth/ReverseProxy.php b/app/Auth/ReverseProxy.php index 1910ad35..d119ca98 100644 --- a/app/Auth/ReverseProxy.php +++ b/app/Auth/ReverseProxy.php @@ -48,7 +48,7 @@ class ReverseProxy extends Base $user = $this->user->getByUsername($login); } - $this->userSession->refresh($user); + $this->userSession->initialize($user); $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); return true; diff --git a/app/Controller/Action.php b/app/Controller/Action.php index 37d1c248..ad136067 100644 --- a/app/Controller/Action.php +++ b/app/Controller/Action.php @@ -119,9 +119,9 @@ class Action extends Base if ($valid) { if ($this->action->create($values) !== false) { - $this->session->flash(t('Your automatic action have been created successfully.')); + $this->flash->success(t('Your automatic action have been created successfully.')); } else { - $this->session->flashError(t('Unable to create your automatic action.')); + $this->flash->failure(t('Unable to create your automatic action.')); } } @@ -158,9 +158,9 @@ class Action extends Base $action = $this->action->getById($this->request->getIntegerParam('action_id')); if (! empty($action) && $this->action->remove($action['id'])) { - $this->session->flash(t('Action removed successfully.')); + $this->flash->success(t('Action removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this action.')); + $this->flash->failure(t('Unable to remove this action.')); } $this->response->redirect($this->helper->url->to('action', 'index', array('project_id' => $project['id']))); diff --git a/app/Controller/Auth.php b/app/Controller/Auth.php index 95ad8d9e..b90e756d 100644 --- a/app/Controller/Auth.php +++ b/app/Controller/Auth.php @@ -43,9 +43,11 @@ class Auth extends Base list($valid, $errors) = $this->authentication->validateForm($values); if ($valid) { - if (! empty($this->session['login_redirect']) && ! filter_var($this->session['login_redirect'], FILTER_VALIDATE_URL)) { - $redirect = $this->session['login_redirect']; - unset($this->session['login_redirect']); + if (isset($this->sessionStorage->redirectAfterLogin) + && ! empty($this->sessionStorage->redirectAfterLogin) + && ! filter_var($this->sessionStorage->redirectAfterLogin, FILTER_VALIDATE_URL)) { + $redirect = $this->sessionStorage->redirectAfterLogin; + unset($this->sessionStorage->redirectAfterLogin); $this->response->redirect($redirect); } @@ -63,7 +65,7 @@ class Auth extends Base public function logout() { $this->authentication->backend('rememberMe')->destroy($this->userSession->getId()); - $this->session->close(); + $this->sessionManager->close(); $this->response->redirect($this->helper->url->to('auth', 'login')); } @@ -78,7 +80,7 @@ class Auth extends Base $builder = new CaptchaBuilder; $builder->build(); - $this->session['captcha'] = $builder->getPhrase(); + $this->sessionStorage->captcha = $builder->getPhrase(); $builder->output(); } } diff --git a/app/Controller/Base.php b/app/Controller/Base.php index 829e0ad2..8630f00c 100644 --- a/app/Controller/Base.php +++ b/app/Controller/Base.php @@ -76,8 +76,7 @@ abstract class Base extends \Kanboard\Core\Base */ public function beforeAction($controller, $action) { - // Start the session - $this->session->open($this->helper->url->dir()); + $this->sessionManager->open(); $this->sendHeaders($action); $this->container['dispatcher']->dispatch('session.bootstrap', new Event); @@ -86,7 +85,7 @@ abstract class Base extends \Kanboard\Core\Base $this->handle2FA($controller, $action); $this->handleAuthorization($controller, $action); - $this->session['has_subtask_inprogress'] = $this->subtask->hasSubtaskInProgress($this->userSession->getId()); + $this->sessionStorage->hasSubtaskInProgress = $this->subtask->hasSubtaskInProgress($this->userSession->getId()); } } @@ -102,7 +101,7 @@ abstract class Base extends \Kanboard\Core\Base $this->response->text('Not Authorized', 401); } - $this->session['login_redirect'] = $this->request->getUri(); + $this->sessionStorage->redirectAfterLogin = $this->request->getUri(); $this->response->redirect($this->helper->url->to('auth', 'login')); } } @@ -269,7 +268,7 @@ abstract class Base extends \Kanboard\Core\Base $project = $this->project->getById($project_id); if (empty($project)) { - $this->session->flashError(t('Project not found.')); + $this->flash->failure(t('Project not found.')); $this->response->redirect($this->helper->url->to('project', 'index')); } diff --git a/app/Controller/Board.php b/app/Controller/Board.php index 2d75db89..7442ff22 100644 --- a/app/Controller/Board.php +++ b/app/Controller/Board.php @@ -242,9 +242,9 @@ class Board extends Base list($valid, ) = $this->taskValidator->validateAssigneeModification($values); if ($valid && $this->taskModification->update($values)) { - $this->session->flash(t('Task updated successfully.')); + $this->flash->success(t('Task updated successfully.')); } else { - $this->session->flashError(t('Unable to update your task.')); + $this->flash->failure(t('Unable to update your task.')); } $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $values['project_id']))); @@ -279,9 +279,9 @@ class Board extends Base list($valid, ) = $this->taskValidator->validateCategoryModification($values); if ($valid && $this->taskModification->update($values)) { - $this->session->flash(t('Task updated successfully.')); + $this->flash->success(t('Task updated successfully.')); } else { - $this->session->flashError(t('Unable to update your task.')); + $this->flash->failure(t('Unable to update your task.')); } $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $values['project_id']))); diff --git a/app/Controller/Category.php b/app/Controller/Category.php index 4aefd9fe..9864348c 100644 --- a/app/Controller/Category.php +++ b/app/Controller/Category.php @@ -22,7 +22,7 @@ class Category extends Base $category = $this->category->getById($this->request->getIntegerParam('category_id')); if (empty($category)) { - $this->session->flashError(t('Category not found.')); + $this->flash->failure(t('Category not found.')); $this->response->redirect($this->helper->url->to('category', 'index', array('project_id' => $project_id))); } @@ -61,10 +61,10 @@ class Category extends Base if ($valid) { if ($this->category->create($values)) { - $this->session->flash(t('Your category have been created successfully.')); + $this->flash->success(t('Your category have been created successfully.')); $this->response->redirect($this->helper->url->to('category', 'index', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to create your category.')); + $this->flash->failure(t('Unable to create your category.')); } } @@ -103,10 +103,10 @@ class Category extends Base if ($valid) { if ($this->category->update($values)) { - $this->session->flash(t('Your category have been updated successfully.')); + $this->flash->success(t('Your category have been updated successfully.')); $this->response->redirect($this->helper->url->to('category', 'index', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to update your category.')); + $this->flash->failure(t('Unable to update your category.')); } } @@ -142,9 +142,9 @@ class Category extends Base $category = $this->getCategory($project['id']); if ($this->category->remove($category['id'])) { - $this->session->flash(t('Category removed successfully.')); + $this->flash->success(t('Category removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this category.')); + $this->flash->failure(t('Unable to remove this category.')); } $this->response->redirect($this->helper->url->to('category', 'index', array('project_id' => $project['id']))); diff --git a/app/Controller/Column.php b/app/Controller/Column.php index d28fb293..b484fe12 100644 --- a/app/Controller/Column.php +++ b/app/Controller/Column.php @@ -55,10 +55,10 @@ class Column extends Base if ($valid) { if ($this->board->addColumn($project['id'], $data['title'], $data['task_limit'], $data['description'])) { - $this->session->flash(t('Board updated successfully.')); + $this->flash->success(t('Board updated successfully.')); $this->response->redirect($this->helper->url->to('column', 'index', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to update this board.')); + $this->flash->failure(t('Unable to update this board.')); } } @@ -98,10 +98,10 @@ class Column extends Base if ($valid) { if ($this->board->updateColumn($values['id'], $values['title'], $values['task_limit'], $values['description'])) { - $this->session->flash(t('Board updated successfully.')); + $this->flash->success(t('Board updated successfully.')); $this->response->redirect($this->helper->url->to('column', 'index', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to update this board.')); + $this->flash->failure(t('Unable to update this board.')); } } @@ -155,9 +155,9 @@ class Column extends Base $column = $this->board->getColumn($this->request->getIntegerParam('column_id')); if (! empty($column) && $this->board->removeColumn($column['id'])) { - $this->session->flash(t('Column removed successfully.')); + $this->flash->success(t('Column removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this column.')); + $this->flash->failure(t('Unable to remove this column.')); } $this->response->redirect($this->helper->url->to('column', 'index', array('project_id' => $project['id']))); diff --git a/app/Controller/Comment.php b/app/Controller/Comment.php index d6cbbf1e..54339e48 100644 --- a/app/Controller/Comment.php +++ b/app/Controller/Comment.php @@ -82,9 +82,9 @@ class Comment extends Base if ($valid) { if ($this->comment->create($values)) { - $this->session->flash(t('Comment added successfully.')); + $this->flash->success(t('Comment added successfully.')); } else { - $this->session->flashError(t('Unable to create your comment.')); + $this->flash->failure(t('Unable to create your comment.')); } if ($ajax) { @@ -131,9 +131,9 @@ class Comment extends Base if ($valid) { if ($this->comment->update($values)) { - $this->session->flash(t('Comment updated successfully.')); + $this->flash->success(t('Comment updated successfully.')); } else { - $this->session->flashError(t('Unable to update your comment.')); + $this->flash->failure(t('Unable to update your comment.')); } $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), 'comment-'.$comment['id'])); @@ -171,9 +171,9 @@ class Comment extends Base $comment = $this->getComment(); if ($this->comment->remove($comment['id'])) { - $this->session->flash(t('Comment removed successfully.')); + $this->flash->success(t('Comment removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this comment.')); + $this->flash->failure(t('Unable to remove this comment.')); } $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), 'comments')); diff --git a/app/Controller/Config.php b/app/Controller/Config.php index 47b844e4..49806144 100644 --- a/app/Controller/Config.php +++ b/app/Controller/Config.php @@ -53,9 +53,9 @@ class Config extends Base if ($this->config->save($values)) { $this->config->reload(); - $this->session->flash(t('Settings saved successfully.')); + $this->flash->success(t('Settings saved successfully.')); } else { - $this->session->flashError(t('Unable to save your settings.')); + $this->flash->failure(t('Unable to save your settings.')); } $this->response->redirect($this->helper->url->to('config', $redirect)); @@ -210,7 +210,7 @@ class Config extends Base { $this->checkCSRFParam(); $this->config->optimizeDatabase(); - $this->session->flash(t('Database optimization done.')); + $this->flash->success(t('Database optimization done.')); $this->response->redirect($this->helper->url->to('config', 'index')); } @@ -226,7 +226,7 @@ class Config extends Base $this->checkCSRFParam(); $this->config->regenerateToken($type.'_token'); - $this->session->flash(t('Token regenerated.')); + $this->flash->success(t('Token regenerated.')); $this->response->redirect($this->helper->url->to('config', $type)); } } diff --git a/app/Controller/Currency.php b/app/Controller/Currency.php index 9d6b0249..118b2c41 100644 --- a/app/Controller/Currency.php +++ b/app/Controller/Currency.php @@ -55,10 +55,10 @@ class Currency extends Base if ($valid) { if ($this->currency->create($values['currency'], $values['rate'])) { - $this->session->flash(t('The currency rate have been added successfully.')); + $this->flash->success(t('The currency rate have been added successfully.')); $this->response->redirect($this->helper->url->to('currency', 'index')); } else { - $this->session->flashError(t('Unable to add this currency rate.')); + $this->flash->failure(t('Unable to add this currency rate.')); } } @@ -76,9 +76,9 @@ class Currency extends Base if ($this->config->save($values)) { $this->config->reload(); - $this->session->flash(t('Settings saved successfully.')); + $this->flash->success(t('Settings saved successfully.')); } else { - $this->session->flashError(t('Unable to save your settings.')); + $this->flash->failure(t('Unable to save your settings.')); } $this->response->redirect($this->helper->url->to('currency', 'index')); diff --git a/app/Controller/Customfilter.php b/app/Controller/Customfilter.php index a152c668..d6863103 100644 --- a/app/Controller/Customfilter.php +++ b/app/Controller/Customfilter.php @@ -44,10 +44,10 @@ class Customfilter extends Base if ($valid) { if ($this->customFilter->create($values)) { - $this->session->flash(t('Your custom filter have been created successfully.')); + $this->flash->success(t('Your custom filter have been created successfully.')); $this->response->redirect($this->helper->url->to('customfilter', 'index', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to create your custom filter.')); + $this->flash->failure(t('Unable to create your custom filter.')); } } @@ -68,9 +68,9 @@ class Customfilter extends Base $this->checkPermission($project, $filter); if ($this->customFilter->remove($filter['id'])) { - $this->session->flash(t('Custom filter removed successfully.')); + $this->flash->success(t('Custom filter removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this custom filter.')); + $this->flash->failure(t('Unable to remove this custom filter.')); } $this->response->redirect($this->helper->url->to('customfilter', 'index', array('project_id' => $project['id']))); @@ -123,10 +123,10 @@ class Customfilter extends Base if ($valid) { if ($this->customFilter->update($values)) { - $this->session->flash(t('Your custom filter have been updated successfully.')); + $this->flash->success(t('Your custom filter have been updated successfully.')); $this->response->redirect($this->helper->url->to('customfilter', 'index', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to update custom filter.')); + $this->flash->failure(t('Unable to update custom filter.')); } } diff --git a/app/Controller/File.php b/app/Controller/File.php index 4d771e2f..b46f7d19 100644 --- a/app/Controller/File.php +++ b/app/Controller/File.php @@ -22,7 +22,7 @@ class File extends Base $task = $this->getTask(); if ($this->request->isPost() && $this->file->uploadScreenshot($task['project_id'], $task['id'], $this->request->getValue('screenshot')) !== false) { - $this->session->flash(t('Screenshot uploaded successfully.')); + $this->flash->success(t('Screenshot uploaded successfully.')); if ($this->request->getStringParam('redirect') === 'board') { $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id']))); @@ -62,7 +62,7 @@ class File extends Base $task = $this->getTask(); if (! $this->file->uploadFiles($task['project_id'], $task['id'], 'files')) { - $this->session->flashError(t('Unable to upload the file.')); + $this->flash->failure(t('Unable to upload the file.')); } $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))); @@ -166,9 +166,9 @@ class File extends Base $file = $this->file->getById($this->request->getIntegerParam('file_id')); if ($file['task_id'] == $task['id'] && $this->file->remove($file['id'])) { - $this->session->flash(t('File removed successfully.')); + $this->flash->success(t('File removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this file.')); + $this->flash->failure(t('Unable to remove this file.')); } $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))); diff --git a/app/Controller/Gantt.php b/app/Controller/Gantt.php index 24d94f02..bd3d92f7 100644 --- a/app/Controller/Gantt.php +++ b/app/Controller/Gantt.php @@ -135,10 +135,10 @@ class Gantt extends Base $task_id = $this->taskCreation->create($values); if ($task_id !== false) { - $this->session->flash(t('Task created successfully.')); + $this->flash->success(t('Task created successfully.')); $this->response->redirect($this->helper->url->to('gantt', 'project', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to create your task.')); + $this->flash->failure(t('Unable to create your task.')); } } diff --git a/app/Controller/Link.php b/app/Controller/Link.php index 0eb3d679..c7f18230 100644 --- a/app/Controller/Link.php +++ b/app/Controller/Link.php @@ -71,10 +71,10 @@ class Link extends Base if ($valid) { if ($this->link->create($values['label'], $values['opposite_label']) !== false) { - $this->session->flash(t('Link added successfully.')); + $this->flash->success(t('Link added successfully.')); $this->response->redirect($this->helper->url->to('link', 'index')); } else { - $this->session->flashError(t('Unable to create your link.')); + $this->flash->failure(t('Unable to create your link.')); } } @@ -112,10 +112,10 @@ class Link extends Base if ($valid) { if ($this->link->update($values)) { - $this->session->flash(t('Link updated successfully.')); + $this->flash->success(t('Link updated successfully.')); $this->response->redirect($this->helper->url->to('link', 'index')); } else { - $this->session->flashError(t('Unable to update your link.')); + $this->flash->failure(t('Unable to update your link.')); } } @@ -148,9 +148,9 @@ class Link extends Base $link = $this->getLink(); if ($this->link->remove($link['id'])) { - $this->session->flash(t('Link removed successfully.')); + $this->flash->success(t('Link removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this link.')); + $this->flash->failure(t('Unable to remove this link.')); } $this->response->redirect($this->helper->url->to('link', 'index')); diff --git a/app/Controller/Oauth.php b/app/Controller/Oauth.php index 8c701cf7..39546148 100644 --- a/app/Controller/Oauth.php +++ b/app/Controller/Oauth.php @@ -51,9 +51,9 @@ class Oauth extends Base $this->checkCSRFParam(); if ($this->authentication->backend($backend)->unlink($this->userSession->getId())) { - $this->session->flash(t('Your external account is not linked anymore to your profile.')); + $this->flash->success(t('Your external account is not linked anymore to your profile.')); } else { - $this->session->flashError(t('Unable to unlink your external account.')); + $this->flash->failure(t('Unable to unlink your external account.')); } $this->response->redirect($this->helper->url->to('user', 'external', array('user_id' => $this->userSession->getId()))); @@ -99,9 +99,9 @@ class Oauth extends Base private function link($backend, $profile) { if (empty($profile)) { - $this->session->flashError(t('External authentication failed')); + $this->flash->failure(t('External authentication failed')); } else { - $this->session->flash(t('Your external account is linked to your profile successfully.')); + $this->flash->success(t('Your external account is linked to your profile successfully.')); $this->authentication->backend($backend)->updateUser($this->userSession->getId(), $profile); } diff --git a/app/Controller/Project.php b/app/Controller/Project.php index f30d70e2..2d9c25de 100644 --- a/app/Controller/Project.php +++ b/app/Controller/Project.php @@ -70,9 +70,9 @@ class Project extends Base $this->checkCSRFParam(); if ($this->project->{$switch.'PublicAccess'}($project['id'])) { - $this->session->flash(t('Project updated successfully.')); + $this->flash->success(t('Project updated successfully.')); } else { - $this->session->flashError(t('Unable to update this project.')); + $this->flash->failure(t('Unable to update this project.')); } $this->response->redirect($this->helper->url->to('project', 'share', array('project_id' => $project['id']))); @@ -95,7 +95,7 @@ class Project extends Base if ($this->request->isPost()) { $this->projectMetadata->save($project['id'], $this->request->getValues()); - $this->session->flash(t('Project updated successfully.')); + $this->flash->success(t('Project updated successfully.')); $this->response->redirect($this->helper->url->to('project', 'integrations', array('project_id' => $project['id']))); } @@ -120,7 +120,7 @@ class Project extends Base if ($this->request->isPost()) { $values = $this->request->getValues(); $this->projectNotification->saveSettings($project['id'], $values); - $this->session->flash(t('Project updated successfully.')); + $this->flash->success(t('Project updated successfully.')); $this->response->redirect($this->helper->url->to('project', 'notifications', array('project_id' => $project['id']))); } @@ -173,10 +173,10 @@ class Project extends Base if ($valid) { if ($this->project->update($values)) { - $this->session->flash(t('Project updated successfully.')); + $this->flash->success(t('Project updated successfully.')); $this->response->redirect($this->helper->url->to('project', 'edit', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to update this project.')); + $this->flash->failure(t('Unable to update this project.')); } } @@ -212,9 +212,9 @@ class Project extends Base if ($valid) { if ($this->project->update($values)) { - $this->session->flash(t('Project updated successfully.')); + $this->flash->success(t('Project updated successfully.')); } else { - $this->session->flashError(t('Unable to update this project.')); + $this->flash->failure(t('Unable to update this project.')); } } @@ -233,9 +233,9 @@ class Project extends Base if ($valid) { if ($this->projectPermission->addMember($values['project_id'], $values['user_id'])) { - $this->session->flash(t('Project updated successfully.')); + $this->flash->success(t('Project updated successfully.')); } else { - $this->session->flashError(t('Unable to update this project.')); + $this->flash->failure(t('Unable to update this project.')); } } @@ -261,9 +261,9 @@ class Project extends Base if ($valid) { if ($this->projectPermission->changeRole($values['project_id'], $values['user_id'], $values['is_owner'])) { - $this->session->flash(t('Project updated successfully.')); + $this->flash->success(t('Project updated successfully.')); } else { - $this->session->flashError(t('Unable to update this project.')); + $this->flash->failure(t('Unable to update this project.')); } } @@ -288,9 +288,9 @@ class Project extends Base if ($valid) { if ($this->projectPermission->revokeMember($values['project_id'], $values['user_id'])) { - $this->session->flash(t('Project updated successfully.')); + $this->flash->success(t('Project updated successfully.')); } else { - $this->session->flashError(t('Unable to update this project.')); + $this->flash->failure(t('Unable to update this project.')); } } @@ -310,9 +310,9 @@ class Project extends Base $this->checkCSRFParam(); if ($this->project->remove($project['id'])) { - $this->session->flash(t('Project removed successfully.')); + $this->flash->success(t('Project removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this project.')); + $this->flash->failure(t('Unable to remove this project.')); } $this->response->redirect($this->helper->url->to('project', 'index')); @@ -338,9 +338,9 @@ class Project extends Base if ($this->request->getStringParam('duplicate') === 'yes') { $values = array_keys($this->request->getValues()); if ($this->projectDuplication->duplicate($project['id'], $values) !== false) { - $this->session->flash(t('Project cloned successfully.')); + $this->flash->success(t('Project cloned successfully.')); } else { - $this->session->flashError(t('Unable to clone this project.')); + $this->flash->failure(t('Unable to clone this project.')); } $this->response->redirect($this->helper->url->to('project', 'index')); @@ -365,9 +365,9 @@ class Project extends Base $this->checkCSRFParam(); if ($this->project->disable($project['id'])) { - $this->session->flash(t('Project disabled successfully.')); + $this->flash->success(t('Project disabled successfully.')); } else { - $this->session->flashError(t('Unable to disable this project.')); + $this->flash->failure(t('Unable to disable this project.')); } $this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project['id']))); @@ -392,9 +392,9 @@ class Project extends Base $this->checkCSRFParam(); if ($this->project->enable($project['id'])) { - $this->session->flash(t('Project activated successfully.')); + $this->flash->success(t('Project activated successfully.')); } else { - $this->session->flashError(t('Unable to activate this project.')); + $this->flash->failure(t('Unable to activate this project.')); } $this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project['id']))); @@ -438,11 +438,11 @@ class Project extends Base $project_id = $this->project->create($values, $this->userSession->getId(), true); if ($project_id > 0) { - $this->session->flash(t('Your project have been created successfully.')); + $this->flash->success(t('Your project have been created successfully.')); $this->response->redirect($this->helper->url->to('project', 'show', array('project_id' => $project_id))); } - $this->session->flashError(t('Unable to create your project.')); + $this->flash->failure(t('Unable to create your project.')); } $this->create($values, $errors); diff --git a/app/Controller/Subtask.php b/app/Controller/Subtask.php index 4ef3e74e..30ddc375 100644 --- a/app/Controller/Subtask.php +++ b/app/Controller/Subtask.php @@ -67,9 +67,9 @@ class Subtask extends Base if ($valid) { if ($this->subtask->create($values)) { - $this->session->flash(t('Sub-task added successfully.')); + $this->flash->success(t('Sub-task added successfully.')); } else { - $this->session->flashError(t('Unable to create your sub-task.')); + $this->flash->failure(t('Unable to create your sub-task.')); } if (isset($values['another_subtask']) && $values['another_subtask'] == 1) { @@ -117,9 +117,9 @@ class Subtask extends Base if ($valid) { if ($this->subtask->update($values)) { - $this->session->flash(t('Sub-task updated successfully.')); + $this->flash->success(t('Sub-task updated successfully.')); } else { - $this->session->flashError(t('Unable to update your sub-task.')); + $this->flash->failure(t('Unable to update your sub-task.')); } $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id']), 'subtasks')); @@ -156,9 +156,9 @@ class Subtask extends Base $subtask = $this->getSubtask(); if ($this->subtask->remove($subtask['id'])) { - $this->session->flash(t('Sub-task removed successfully.')); + $this->flash->success(t('Sub-task removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this sub-task.')); + $this->flash->failure(t('Unable to remove this sub-task.')); } $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id']), 'subtasks')); @@ -178,7 +178,7 @@ class Subtask extends Base $this->subtask->toggleStatus($subtask['id']); if ($redirect === 'board') { - $this->session['has_subtask_inprogress'] = $this->subtask->hasSubtaskInProgress($this->userSession->getId()); + $this->sessionStorage->hasSubtaskInProgress = $this->subtask->hasSubtaskInProgress($this->userSession->getId()); $this->response->html($this->template->render('board/tooltip_subtasks', array( 'subtasks' => $this->subtask->getAll($task['id']), diff --git a/app/Controller/Swimlane.php b/app/Controller/Swimlane.php index 0b29f598..5229621c 100644 --- a/app/Controller/Swimlane.php +++ b/app/Controller/Swimlane.php @@ -24,7 +24,7 @@ class Swimlane extends Base $swimlane = $this->swimlane->getById($this->request->getIntegerParam('swimlane_id')); if (empty($swimlane)) { - $this->session->flashError(t('Swimlane not found.')); + $this->flash->failure(t('Swimlane not found.')); $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project_id))); } @@ -64,10 +64,10 @@ class Swimlane extends Base if ($valid) { if ($this->swimlane->create($values)) { - $this->session->flash(t('Your swimlane have been created successfully.')); + $this->flash->success(t('Your swimlane have been created successfully.')); $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to create your swimlane.')); + $this->flash->failure(t('Unable to create your swimlane.')); } } @@ -88,10 +88,10 @@ class Swimlane extends Base if ($valid) { if ($this->swimlane->updateDefault($values)) { - $this->session->flash(t('The default swimlane have been updated successfully.')); + $this->flash->success(t('The default swimlane have been updated successfully.')); $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to update this swimlane.')); + $this->flash->failure(t('Unable to update this swimlane.')); } } @@ -130,10 +130,10 @@ class Swimlane extends Base if ($valid) { if ($this->swimlane->update($values)) { - $this->session->flash(t('Swimlane updated successfully.')); + $this->flash->success(t('Swimlane updated successfully.')); $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id']))); } else { - $this->session->flashError(t('Unable to update this swimlane.')); + $this->flash->failure(t('Unable to update this swimlane.')); } } @@ -169,9 +169,9 @@ class Swimlane extends Base $swimlane_id = $this->request->getIntegerParam('swimlane_id'); if ($this->swimlane->remove($project['id'], $swimlane_id)) { - $this->session->flash(t('Swimlane removed successfully.')); + $this->flash->success(t('Swimlane removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this swimlane.')); + $this->flash->failure(t('Unable to remove this swimlane.')); } $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id']))); @@ -189,9 +189,9 @@ class Swimlane extends Base $swimlane_id = $this->request->getIntegerParam('swimlane_id'); if ($this->swimlane->disable($project['id'], $swimlane_id)) { - $this->session->flash(t('Swimlane updated successfully.')); + $this->flash->success(t('Swimlane updated successfully.')); } else { - $this->session->flashError(t('Unable to update this swimlane.')); + $this->flash->failure(t('Unable to update this swimlane.')); } $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id']))); @@ -209,9 +209,9 @@ class Swimlane extends Base $swimlane_id = $this->request->getIntegerParam('swimlane_id'); if ($this->swimlane->enable($project['id'], $swimlane_id)) { - $this->session->flash(t('Swimlane updated successfully.')); + $this->flash->success(t('Swimlane updated successfully.')); } else { - $this->session->flashError(t('Unable to update this swimlane.')); + $this->flash->failure(t('Unable to update this swimlane.')); } $this->response->redirect($this->helper->url->to('swimlane', 'index', array('project_id' => $project['id']))); diff --git a/app/Controller/Task.php b/app/Controller/Task.php index 894802d8..e71b2017 100644 --- a/app/Controller/Task.php +++ b/app/Controller/Task.php @@ -159,9 +159,9 @@ class Task extends Base $this->checkCSRFParam(); if ($this->task->remove($task['id'])) { - $this->session->flash(t('Task removed successfully.')); + $this->flash->success(t('Task removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this task.')); + $this->flash->failure(t('Unable to remove this task.')); } $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id']))); diff --git a/app/Controller/TaskImport.php b/app/Controller/TaskImport.php index 0e9d2169..f09c14ce 100644 --- a/app/Controller/TaskImport.php +++ b/app/Controller/TaskImport.php @@ -52,9 +52,9 @@ class TaskImport extends Base $csv->read($filename, array($this->taskImport, 'import')); if ($this->taskImport->counter > 0) { - $this->session->flash(t('%d task(s) have been imported successfully.', $this->taskImport->counter)); + $this->flash->success(t('%d task(s) have been imported successfully.', $this->taskImport->counter)); } else { - $this->session->flashError(t('Nothing have been imported!')); + $this->flash->failure(t('Nothing have been imported!')); } $this->response->redirect($this->helper->url->to('taskImport', 'step1', array('project_id' => $project['id']))); diff --git a/app/Controller/Taskcreation.php b/app/Controller/Taskcreation.php index e47cd1b7..cffa9d74 100644 --- a/app/Controller/Taskcreation.php +++ b/app/Controller/Taskcreation.php @@ -59,10 +59,10 @@ class Taskcreation extends Base list($valid, $errors) = $this->taskValidator->validateCreation($values); if ($valid && $this->taskCreation->create($values)) { - $this->session->flash(t('Task created successfully.')); + $this->flash->success(t('Task created successfully.')); $this->afterSave($project, $values); } else { - $this->session->flashError(t('Unable to create your task.')); + $this->flash->failure(t('Unable to create your task.')); } $this->create($values, $errors); diff --git a/app/Controller/Taskduplication.php b/app/Controller/Taskduplication.php index 79f498fc..9cd684eb 100644 --- a/app/Controller/Taskduplication.php +++ b/app/Controller/Taskduplication.php @@ -24,10 +24,10 @@ class Taskduplication extends Base $task_id = $this->taskDuplication->duplicate($task['id']); if ($task_id > 0) { - $this->session->flash(t('Task created successfully.')); + $this->flash->success(t('Task created successfully.')); $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task_id))); } else { - $this->session->flashError(t('Unable to create this task.')); + $this->flash->failure(t('Unable to create this task.')); $this->response->redirect($this->helper->url->to('taskduplication', 'duplicate', array('project_id' => $task['project_id'], 'task_id' => $task['id']))); } } @@ -56,11 +56,11 @@ class Taskduplication extends Base $values['column_id'], $values['category_id'], $values['owner_id'])) { - $this->session->flash(t('Task updated successfully.')); + $this->flash->success(t('Task updated successfully.')); $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $values['project_id'], 'task_id' => $task['id']))); } - $this->session->flashError(t('Unable to update your task.')); + $this->flash->failure(t('Unable to update your task.')); } $this->chooseDestination($task, 'task_duplication/move'); @@ -86,12 +86,12 @@ class Taskduplication extends Base ); if ($task_id > 0) { - $this->session->flash(t('Task created successfully.')); + $this->flash->success(t('Task created successfully.')); $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $values['project_id'], 'task_id' => $task_id))); } } - $this->session->flashError(t('Unable to create your task.')); + $this->flash->failure(t('Unable to create your task.')); } $this->chooseDestination($task, 'task_duplication/copy'); diff --git a/app/Controller/Tasklink.php b/app/Controller/Tasklink.php index 587769ee..068bf16d 100644 --- a/app/Controller/Tasklink.php +++ b/app/Controller/Tasklink.php @@ -73,7 +73,7 @@ class Tasklink extends Base if ($valid) { if ($this->taskLink->create($values['task_id'], $values['opposite_task_id'], $values['link_id'])) { - $this->session->flash(t('Link added successfully.')); + $this->flash->success(t('Link added successfully.')); if ($ajax) { $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id']))); @@ -83,7 +83,7 @@ class Tasklink extends Base } $errors = array('title' => array(t('The exact same link already exists'))); - $this->session->flashError(t('Unable to create your link.')); + $this->flash->failure(t('Unable to create your link.')); } $this->create($values, $errors); @@ -129,11 +129,11 @@ class Tasklink extends Base if ($valid) { if ($this->taskLink->update($values['id'], $values['task_id'], $values['opposite_task_id'], $values['link_id'])) { - $this->session->flash(t('Link updated successfully.')); + $this->flash->success(t('Link updated successfully.')); $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])).'#links'); } - $this->session->flashError(t('Unable to update your link.')); + $this->flash->failure(t('Unable to update your link.')); } $this->edit($values, $errors); @@ -166,9 +166,9 @@ class Tasklink extends Base $task = $this->getTask(); if ($this->taskLink->remove($this->request->getIntegerParam('link_id'))) { - $this->session->flash(t('Link removed successfully.')); + $this->flash->success(t('Link removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this link.')); + $this->flash->failure(t('Unable to remove this link.')); } $this->response->redirect($this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])).'#links'); diff --git a/app/Controller/Taskmodification.php b/app/Controller/Taskmodification.php index b1105dcc..02b09a36 100644 --- a/app/Controller/Taskmodification.php +++ b/app/Controller/Taskmodification.php @@ -35,9 +35,9 @@ class Taskmodification extends Base list($valid, ) = $this->taskValidator->validateTimeModification($values); if ($valid && $this->taskModification->update($values)) { - $this->session->flash(t('Task updated successfully.')); + $this->flash->success(t('Task updated successfully.')); } else { - $this->session->flashError(t('Unable to update your task.')); + $this->flash->failure(t('Unable to update your task.')); } $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id']))); @@ -60,9 +60,9 @@ class Taskmodification extends Base if ($valid) { if ($this->taskModification->update($values)) { - $this->session->flash(t('Task updated successfully.')); + $this->flash->success(t('Task updated successfully.')); } else { - $this->session->flashError(t('Unable to update your task.')); + $this->flash->failure(t('Unable to update your task.')); } if ($ajax) { @@ -140,7 +140,7 @@ class Taskmodification extends Base list($valid, $errors) = $this->taskValidator->validateModification($values); if ($valid && $this->taskModification->update($values)) { - $this->session->flash(t('Task updated successfully.')); + $this->flash->success(t('Task updated successfully.')); if ($this->request->isAjax()) { $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $task['project_id']))); @@ -148,7 +148,7 @@ class Taskmodification extends Base $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id']))); } } else { - $this->session->flashError(t('Unable to update your task.')); + $this->flash->failure(t('Unable to update your task.')); $this->edit($values, $errors); } } @@ -169,9 +169,9 @@ class Taskmodification extends Base if ($valid) { if ($this->taskModification->update($values)) { - $this->session->flash(t('Task updated successfully.')); + $this->flash->success(t('Task updated successfully.')); } else { - $this->session->flashError(t('Unable to update your task.')); + $this->flash->failure(t('Unable to update your task.')); } $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id']))); diff --git a/app/Controller/Taskstatus.php b/app/Controller/Taskstatus.php index c0421ea7..b03baebf 100644 --- a/app/Controller/Taskstatus.php +++ b/app/Controller/Taskstatus.php @@ -40,9 +40,9 @@ class Taskstatus extends Base $this->checkCSRFParam(); if ($this->taskStatus->$method($task['id'])) { - $this->session->flash($success_message); + $this->flash->success($success_message); } else { - $this->session->flashError($failure_message); + $this->flash->failure($failure_message); } if ($this->request->getStringParam('redirect') === 'board') { diff --git a/app/Controller/Twofactor.php b/app/Controller/Twofactor.php index 179241f8..a7368d6b 100644 --- a/app/Controller/Twofactor.php +++ b/app/Controller/Twofactor.php @@ -72,9 +72,9 @@ class Twofactor extends User } // Allow the user to test or disable the feature - $_SESSION['user']['twofactor_activated'] = false; + $this->userSession->disable2FA(); - $this->session->flash(t('User updated successfully.')); + $this->flash->success(t('User updated successfully.')); $this->response->redirect($this->helper->url->to('twofactor', 'index', array('user_id' => $user['id']))); } @@ -92,9 +92,9 @@ class Twofactor extends User $values = $this->request->getValues(); if (! empty($values['code']) && $otp->checkTotp(Base32::decode($user['twofactor_secret']), $values['code'])) { - $this->session->flash(t('The two factor authentication code is valid.')); + $this->flash->success(t('The two factor authentication code is valid.')); } else { - $this->session->flashError(t('The two factor authentication code is not valid.')); + $this->flash->failure(t('The two factor authentication code is not valid.')); } $this->response->redirect($this->helper->url->to('twofactor', 'index', array('user_id' => $user['id']))); @@ -114,11 +114,11 @@ class Twofactor extends User $values = $this->request->getValues(); if (! empty($values['code']) && $otp->checkTotp(Base32::decode($user['twofactor_secret']), $values['code'])) { - $this->session['2fa_validated'] = true; - $this->session->flash(t('The two factor authentication code is valid.')); + $this->sessionStorage->postAuth['validated'] = true; + $this->flash->success(t('The two factor authentication code is valid.')); $this->response->redirect($this->helper->url->to('app', 'index')); } else { - $this->session->flashError(t('The two factor authentication code is not valid.')); + $this->flash->failure(t('The two factor authentication code is not valid.')); $this->response->redirect($this->helper->url->to('twofactor', 'code')); } } diff --git a/app/Controller/User.php b/app/Controller/User.php index 8526fb57..22622d17 100644 --- a/app/Controller/User.php +++ b/app/Controller/User.php @@ -99,10 +99,10 @@ class User extends Base $this->userNotificationType->saveSelectedTypes($user_id, array(MailNotification::TYPE)); } - $this->session->flash(t('User created successfully.')); + $this->flash->success(t('User created successfully.')); $this->response->redirect($this->helper->url->to('user', 'show', array('user_id' => $user_id))); } else { - $this->session->flashError(t('Unable to create your user.')); + $this->flash->failure(t('Unable to create your user.')); $values['project_id'] = $project_id; } } @@ -201,7 +201,7 @@ class User extends Base if ($this->request->isPost()) { $values = $this->request->getValues(); $this->userNotification->saveSettings($user['id'], $values); - $this->session->flash(t('User updated successfully.')); + $this->flash->success(t('User updated successfully.')); $this->response->redirect($this->helper->url->to('user', 'notifications', array('user_id' => $user['id']))); } @@ -226,7 +226,7 @@ class User extends Base if ($this->request->isPost()) { $values = $this->request->getValues(); $this->userMetadata->save($user['id'], $values); - $this->session->flash(t('User updated successfully.')); + $this->flash->success(t('User updated successfully.')); $this->response->redirect($this->helper->url->to('user', 'integrations', array('user_id' => $user['id']))); } @@ -264,9 +264,9 @@ class User extends Base $this->checkCSRFParam(); if ($this->user->{$switch.'PublicAccess'}($user['id'])) { - $this->session->flash(t('User updated successfully.')); + $this->flash->success(t('User updated successfully.')); } else { - $this->session->flashError(t('Unable to update this user.')); + $this->flash->failure(t('Unable to update this user.')); } $this->response->redirect($this->helper->url->to('user', 'share', array('user_id' => $user['id']))); @@ -295,9 +295,9 @@ class User extends Base if ($valid) { if ($this->user->update($values)) { - $this->session->flash(t('Password modified successfully.')); + $this->flash->success(t('Password modified successfully.')); } else { - $this->session->flashError(t('Unable to change the password.')); + $this->flash->failure(t('Unable to change the password.')); } $this->response->redirect($this->helper->url->to('user', 'show', array('user_id' => $user['id']))); @@ -344,9 +344,9 @@ class User extends Base if ($valid) { if ($this->user->update($values)) { - $this->session->flash(t('User updated successfully.')); + $this->flash->success(t('User updated successfully.')); } else { - $this->session->flashError(t('Unable to update your user.')); + $this->flash->failure(t('Unable to update your user.')); } $this->response->redirect($this->helper->url->to('user', 'show', array('user_id' => $user['id']))); @@ -381,9 +381,9 @@ class User extends Base if ($valid) { if ($this->user->update($values)) { - $this->session->flash(t('User updated successfully.')); + $this->flash->success(t('User updated successfully.')); } else { - $this->session->flashError(t('Unable to update your user.')); + $this->flash->failure(t('Unable to update your user.')); } $this->response->redirect($this->helper->url->to('user', 'authentication', array('user_id' => $user['id']))); @@ -410,9 +410,9 @@ class User extends Base $this->checkCSRFParam(); if ($this->user->remove($user['id'])) { - $this->session->flash(t('User removed successfully.')); + $this->flash->success(t('User removed successfully.')); } else { - $this->session->flashError(t('Unable to remove this user.')); + $this->flash->failure(t('Unable to remove this user.')); } $this->response->redirect($this->helper->url->to('user', 'index')); diff --git a/app/Controller/UserImport.php b/app/Controller/UserImport.php index 32b9a865..cbc5aa14 100644 --- a/app/Controller/UserImport.php +++ b/app/Controller/UserImport.php @@ -46,9 +46,9 @@ class UserImport extends Base $csv->read($filename, array($this->userImport, 'import')); if ($this->userImport->counter > 0) { - $this->session->flash(t('%d user(s) have been imported successfully.', $this->userImport->counter)); + $this->flash->success(t('%d user(s) have been imported successfully.', $this->userImport->counter)); } else { - $this->session->flashError(t('Nothing have been imported!')); + $this->flash->failure(t('Nothing have been imported!')); } $this->response->redirect($this->helper->url->to('userImport', 'step1')); diff --git a/app/Core/Base.php b/app/Core/Base.php index 11f4e31b..d3171024 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -10,6 +10,9 @@ use Pimple\Container; * @package core * @author Frederic Guillot * + * @property \Kanboard\Core\Session\SessionManager $sessionManager + * @property \Kanboard\Core\Session\SessionStorage $sessionStorage + * @property \Kanboard\Core\Session\FlashMessage $flash * @property \Kanboard\Core\Helper $helper * @property \Kanboard\Core\Mail\Client $emailClient * @property \Kanboard\Core\Paginator $paginator @@ -17,7 +20,6 @@ use Pimple\Container; * @property \Kanboard\Core\Http\Request $request * @property \Kanboard\Core\Http\Router $router * @property \Kanboard\Core\Http\Response $response - * @property \Kanboard\Core\Session $session * @property \Kanboard\Core\Template $template * @property \Kanboard\Core\OAuth2 $oauth * @property \Kanboard\Core\Lexer $lexer diff --git a/app/Core/Mail/Client.php b/app/Core/Mail/Client.php index 52caef73..7b4268bd 100644 --- a/app/Core/Mail/Client.php +++ b/app/Core/Mail/Client.php @@ -51,7 +51,7 @@ class Client extends Base $author = 'Kanboard'; if ($this->userSession->isLogged()) { - $author = e('%s via Kanboard', $this->user->getFullname($this->session['user'])); + $author = e('%s via Kanboard', $this->helper->user->getFullname()); } $this->getTransport(MAIL_TRANSPORT)->sendEmail($email, $name, $subject, $html, $author); diff --git a/app/Core/Security/Token.php b/app/Core/Security/Token.php index 7aca08af..2bb66ef2 100644 --- a/app/Core/Security/Token.php +++ b/app/Core/Security/Token.php @@ -38,12 +38,12 @@ class Token extends Base */ public function getCSRFToken() { - if (! isset($_SESSION['csrf_tokens'])) { - $_SESSION['csrf_tokens'] = array(); + if (! isset($this->sessionStorage->csrf)) { + $this->sessionStorage->csrf = array(); } $nonce = self::getToken(); - $_SESSION['csrf_tokens'][$nonce] = true; + $this->sessionStorage->csrf[$nonce] = true; return $nonce; } @@ -57,8 +57,8 @@ class Token extends Base */ public function validateCSRFToken($token) { - if (isset($_SESSION['csrf_tokens'][$token])) { - unset($_SESSION['csrf_tokens'][$token]); + if (isset($this->sessionStorage->csrf[$token])) { + unset($this->sessionStorage->csrf[$token]); return true; } diff --git a/app/Core/Session.php b/app/Core/Session.php deleted file mode 100644 index dd1e760e..00000000 --- a/app/Core/Session.php +++ /dev/null @@ -1,144 +0,0 @@ -setMessage('success', $message); + } + + /** + * Add failure message + * + * @access public + * @param string $message + */ + public function failure($message) + { + $this->setMessage('failure', $message); + } + + /** + * Add new flash message + * + * @access public + * @param string $key + * @param string $message + */ + public function setMessage($key, $message) + { + if (! isset($this->sessionStorage->flash)) { + $this->sessionStorage->flash = array(); + } + + $this->sessionStorage->flash[$key] = $message; + } + + /** + * Get flash message + * + * @access public + * @param string $key + * @return string + */ + public function getMessage($key) + { + $message = ''; + + if (isset($this->sessionStorage->flash[$key])) { + $message = $this->sessionStorage->flash[$key]; + unset($this->sessionStorage->flash[$key]); + } + + return $message; + } +} diff --git a/app/Core/Session/SessionManager.php b/app/Core/Session/SessionManager.php new file mode 100644 index 00000000..6153efeb --- /dev/null +++ b/app/Core/Session/SessionManager.php @@ -0,0 +1,102 @@ +configure(); + + if (ini_get('session.auto_start') == 1) { + session_destroy(); + } + + session_name('KB_SID'); + session_start(); + + $this->container['sessionStorage']->setStorage($_SESSION); + } + + /** + * Destroy the session + * + * @access public + */ + public function close() + { + // Destroy the session cookie + $params = session_get_cookie_params(); + + setcookie( + session_name(), + '', + time() - 42000, + $params['path'], + $params['domain'], + $params['secure'], + $params['httponly'] + ); + + session_unset(); + session_destroy(); + } + + /** + * Define session settings + * + * @access private + */ + private function configure() + { + // Session cookie: HttpOnly and secure flags + session_set_cookie_params( + SESSION_DURATION, + $this->helper->url->dir() ?: '/', + null, + Request::isHTTPS(), + true + ); + + // Avoid session id in the URL + ini_set('session.use_only_cookies', '1'); + ini_set('session.use_trans_sid', '0'); + + // Enable strict mode + ini_set('session.use_strict_mode', '1'); + + // Better session hash + ini_set('session.hash_function', 'sha512'); + ini_set('session.hash_bits_per_character', 6); + + // Set an additional entropy + ini_set('session.entropy_file', '/dev/urandom'); + ini_set('session.entropy_length', '256'); + } +} diff --git a/app/Core/Session/SessionStorage.php b/app/Core/Session/SessionStorage.php new file mode 100644 index 00000000..54d803f7 --- /dev/null +++ b/app/Core/Session/SessionStorage.php @@ -0,0 +1,71 @@ +storage =& $storage; + + // Load dynamically existing session variables into object properties + foreach ($storage as $key => $value) { + $this->$key = $value; + } + } + + /** + * Get all session variables + * + * @access public + * @return array + */ + public function getAll() + { + $session = get_object_vars($this); + unset($session['storage']); + + return $session; + } + + /** + * Copy class properties to external storage + * + * @access public + */ + public function __destruct() + { + $this->storage = $this->getAll(); + } +} diff --git a/app/Helper/App.php b/app/Helper/App.php index 19801fa8..33729f2b 100644 --- a/app/Helper/App.php +++ b/app/Helper/App.php @@ -62,18 +62,17 @@ class App extends \Kanboard\Core\Base */ public function flashMessage() { - $html = ''; + $success_message = $this->flash->getMessage('success'); + $failure_message = $this->flash->getMessage('failure'); - if (isset($this->session['flash_message'])) { - $html = '
'.$this->helper->e($this->session['flash_message']).'
'; - unset($this->session['flash_message']); - unset($this->session['flash_error_message']); - } elseif (isset($this->session['flash_error_message'])) { - $html = '
'.$this->helper->e($this->session['flash_error_message']).'
'; - unset($this->session['flash_message']); - unset($this->session['flash_error_message']); + if (! empty($success_message)) { + return '
'.$this->helper->e($success_message).'
'; } - return $html; + if (! empty($failure_message)) { + return '
'.$this->helper->e($failure_message).'
'; + } + + return ''; } } diff --git a/app/Helper/Subtask.php b/app/Helper/Subtask.php index 1f367b27..4bb26e77 100644 --- a/app/Helper/Subtask.php +++ b/app/Helper/Subtask.php @@ -20,7 +20,7 @@ class Subtask extends \Kanboard\Core\Base */ public function toggleStatus(array $subtask, $redirect) { - if ($subtask['status'] == 0 && isset($this->session['has_subtask_inprogress']) && $this->session['has_subtask_inprogress'] === true) { + if ($subtask['status'] == 0 && isset($this->sessionStorage->hasSubtaskInProgress) && $this->sessionStorage->hasSubtaskInProgress === true) { return $this->helper->url->link( trim($this->template->render('subtask/icons', array('subtask' => $subtask))) . $this->helper->e($subtask['title']), 'subtask', diff --git a/app/Helper/User.php b/app/Helper/User.php index 9cd39bd9..9ef20b38 100644 --- a/app/Helper/User.php +++ b/app/Helper/User.php @@ -136,7 +136,7 @@ class User extends \Kanboard\Core\Base */ public function getFullname(array $user = array()) { - return $this->user->getFullname(empty($user) ? $_SESSION['user'] : $user); + return $this->user->getFullname(empty($user) ? $this->sessionStorage->user : $user); } /** diff --git a/app/Model/Authentication.php b/app/Model/Authentication.php index 11e32313..83d85433 100644 --- a/app/Model/Authentication.php +++ b/app/Model/Authentication.php @@ -45,11 +45,11 @@ class Authentication extends Base // Check if the user session match an existing user $userNotFound = ! $this->user->exists($this->userSession->getId()); - $reverseProxyWrongUser = REVERSE_PROXY_AUTH && $this->backend('reverseProxy')->getUsername() !== $_SESSION['user']['username']; + $reverseProxyWrongUser = REVERSE_PROXY_AUTH && $this->backend('reverseProxy')->getUsername() !== $this->userSession->getUsername(); if ($userNotFound || $reverseProxyWrongUser) { $this->backend('rememberMe')->destroy($this->userSession->getId()); - $this->session->close(); + $this->sessionManager->close(); return false; } @@ -176,8 +176,12 @@ class Authentication extends Base public function validateFormCaptcha(array $values) { if ($this->hasCaptcha($values['username'])) { + if (! isset($this->sessionStorage->captcha)) { + return false; + } + $builder = new CaptchaBuilder; - $builder->setPhrase($this->session['captcha']); + $builder->setPhrase($this->sessionStorage->captcha); return $builder->testPhrase(isset($values['captcha']) ? $values['captcha'] : ''); } diff --git a/app/Model/Config.php b/app/Model/Config.php index 84a968e3..6a6f8a5a 100644 --- a/app/Model/Config.php +++ b/app/Model/Config.php @@ -4,7 +4,7 @@ namespace Kanboard\Model; use Kanboard\Core\Translator; use Kanboard\Core\Security\Token; -use Kanboard\Core\Session; +use Kanboard\Core\Session\SessionManager; /** * Config model @@ -145,8 +145,8 @@ class Config extends Setting */ public function getCurrentLanguage() { - if ($this->userSession->isLogged() && ! empty($this->session['user']['language'])) { - return $this->session['user']['language']; + if ($this->userSession->isLogged() && ! empty($this->sessionStorage->user['language'])) { + return $this->sessionStorage->user['language']; } return $this->get('application_language', 'en_US'); @@ -162,17 +162,17 @@ class Config extends Setting */ public function get($name, $default_value = '') { - if (! Session::isOpen()) { + if (! SessionManager::isOpen()) { return $this->getOption($name, $default_value); } // Cache config in session - if (! isset($this->session['config'][$name])) { - $this->session['config'] = $this->getAll(); + if (! isset($this->sessionStorage->config[$name])) { + $this->sessionStorage->config = $this->getAll(); } - if (! empty($this->session['config'][$name])) { - return $this->session['config'][$name]; + if (! empty($this->sessionStorage->config[$name])) { + return $this->sessionStorage->config[$name]; } return $default_value; @@ -185,7 +185,7 @@ class Config extends Setting */ public function reload() { - $this->session['config'] = $this->getAll(); + $this->sessionStorage->config = $this->getAll(); $this->setupTranslations(); } @@ -207,8 +207,8 @@ class Config extends Setting */ public function getCurrentTimezone() { - if ($this->userSession->isLogged() && ! empty($this->session['user']['timezone'])) { - return $this->session['user']['timezone']; + if ($this->userSession->isLogged() && ! empty($this->sessionStorage->user['timezone'])) { + return $this->sessionStorage->user['timezone']; } return $this->get('application_timezone', 'UTC'); diff --git a/app/Model/User.php b/app/Model/User.php index dc00c0c5..88361ce8 100644 --- a/app/Model/User.php +++ b/app/Model/User.php @@ -5,7 +5,7 @@ namespace Kanboard\Model; use PicoDb\Database; use SimpleValidator\Validator; use SimpleValidator\Validators; -use Kanboard\Core\Session; +use Kanboard\Core\Session\SessionManager; use Kanboard\Core\Security\Token; /** @@ -320,8 +320,8 @@ class User extends Base $result = $this->db->table(self::TABLE)->eq('id', $values['id'])->update($values); // If the user is connected refresh his session - if (Session::isOpen() && $this->userSession->getId() == $values['id']) { - $this->userSession->refresh(); + if (SessionManager::isOpen() && $this->userSession->getId() == $values['id']) { + $this->userSession->initialize($this->getById($this->userSession->getId())); } return $result; @@ -587,7 +587,7 @@ class User extends Base if ($v->execute()) { // Check password - if ($this->authentication->authenticate($this->session['user']['username'], $values['current_password'])) { + if ($this->authentication->authenticate($this->userSession->getUsername(), $values['current_password'])) { return array(true, array()); } else { return array(false, array('current_password' => array(t('Wrong password')))); diff --git a/app/Model/UserSession.php b/app/Model/UserSession.php index 1778114e..a687952b 100644 --- a/app/Model/UserSession.php +++ b/app/Model/UserSession.php @@ -11,17 +11,13 @@ namespace Kanboard\Model; class UserSession extends Base { /** - * Update user session information + * Update user session * * @access public - * @param array $user User data + * @param array $user */ - public function refresh(array $user = array()) + public function initialize(array $user) { - if (empty($user)) { - $user = $this->user->getById($this->userSession->getId()); - } - if (isset($user['password'])) { unset($user['password']); } @@ -31,12 +27,13 @@ class UserSession extends Base } $user['id'] = (int) $user['id']; - $user['is_admin'] = (bool) $user['is_admin']; - $user['is_project_admin'] = (bool) $user['is_project_admin']; - $user['is_ldap_user'] = (bool) $user['is_ldap_user']; - $user['twofactor_activated'] = (bool) $user['twofactor_activated']; + $user['is_admin'] = isset($user['is_admin']) ? (bool) $user['is_admin'] : false; + $user['is_project_admin'] = isset($user['is_project_admin']) ? (bool) $user['is_project_admin'] : false; + $user['is_ldap_user'] = isset($user['is_ldap_user']) ? (bool) $user['is_ldap_user'] : false; + $user['twofactor_activated'] = isset($user['twofactor_activated']) ? (bool) $user['twofactor_activated'] : false; - $this->session['user'] = $user; + $this->sessionStorage->user = $user; + $this->sessionStorage->postAuth = array('validated' => false); } /** @@ -47,7 +44,7 @@ class UserSession extends Base */ public function check2FA() { - return isset($this->session['2fa_validated']) && $this->session['2fa_validated'] === true; + return isset($this->sessionStorage->postAuth['validated']) && $this->sessionStorage->postAuth['validated'] === true; } /** @@ -58,7 +55,17 @@ class UserSession extends Base */ public function has2FA() { - return isset($this->session['user']['twofactor_activated']) && $this->session['user']['twofactor_activated'] === true; + return isset($this->sessionStorage->user['twofactor_activated']) && $this->sessionStorage->user['twofactor_activated'] === true; + } + + /** + * Disable 2FA for the current session + * + * @access public + */ + public function disable2FA() + { + $this->sessionStorage->user['twofactor_activated'] = false; } /** @@ -69,7 +76,7 @@ class UserSession extends Base */ public function isAdmin() { - return isset($this->session['user']['is_admin']) && $this->session['user']['is_admin'] === true; + return isset($this->sessionStorage->user['is_admin']) && $this->sessionStorage->user['is_admin'] === true; } /** @@ -80,7 +87,7 @@ class UserSession extends Base */ public function isProjectAdmin() { - return isset($this->session['user']['is_project_admin']) && $this->session['user']['is_project_admin'] === true; + return isset($this->sessionStorage->user['is_project_admin']) && $this->sessionStorage->user['is_project_admin'] === true; } /** @@ -91,7 +98,18 @@ class UserSession extends Base */ public function getId() { - return isset($this->session['user']['id']) ? (int) $this->session['user']['id'] : 0; + return isset($this->sessionStorage->user['id']) ? (int) $this->sessionStorage->user['id'] : 0; + } + + /** + * Get username + * + * @access public + * @return integer + */ + public function getUsername() + { + return isset($this->sessionStorage->user['username']) ? $this->sessionStorage->user['username'] : ''; } /** @@ -102,7 +120,7 @@ class UserSession extends Base */ public function isLogged() { - return ! empty($this->session['user']); + return isset($this->sessionStorage->user) && ! empty($this->sessionStorage->user); } /** @@ -114,7 +132,7 @@ class UserSession extends Base */ public function getFilters($project_id) { - return ! empty($_SESSION['filters'][$project_id]) ? $_SESSION['filters'][$project_id] : 'status:open'; + return ! empty($this->sessionStorage->filters[$project_id]) ? $this->sessionStorage->filters[$project_id] : 'status:open'; } /** @@ -126,7 +144,7 @@ class UserSession extends Base */ public function setFilters($project_id, $filters) { - $_SESSION['filters'][$project_id] = $filters; + $this->sessionStorage->filters[$project_id] = $filters; } /** @@ -138,7 +156,7 @@ class UserSession extends Base */ public function isBoardCollapsed($project_id) { - return ! empty($_SESSION['board_collapsed'][$project_id]) ? $_SESSION['board_collapsed'][$project_id] : false; + return ! empty($this->sessionStorage->boardCollapsed[$project_id]) ? $this->sessionStorage->boardCollapsed[$project_id] : false; } /** @@ -146,11 +164,11 @@ class UserSession extends Base * * @access public * @param integer $project_id - * @param boolean $collapsed + * @param boolean $is_collapsed */ - public function setBoardDisplayMode($project_id, $collapsed) + public function setBoardDisplayMode($project_id, $is_collapsed) { - $_SESSION['board_collapsed'][$project_id] = $collapsed; + $this->sessionStorage->boardCollapsed[$project_id] = $is_collapsed; } /** @@ -161,7 +179,7 @@ class UserSession extends Base */ public function setCommentSorting($order) { - $this->session['comment_sorting'] = $order; + $this->sessionStorage->commentSorting = $order; } /** @@ -172,6 +190,6 @@ class UserSession extends Base */ public function getCommentSorting() { - return $this->session['comment_sorting'] ?: 'ASC'; + return empty($this->sessionStorage->commentSorting) ? 'ASC' : $this->sessionStorage->commentSorting; } } diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index 2699de17..9c9bc233 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -85,7 +85,6 @@ class ClassProvider implements ServiceProviderInterface 'DateParser', 'Helper', 'Lexer', - 'Session', 'Template', ), 'Core\Http' => array( @@ -158,5 +157,7 @@ class ClassProvider implements ServiceProviderInterface $container['pluginLoader'] = new Loader($container); $container['cspRules'] = array('style-src' => "'self' 'unsafe-inline'", 'img-src' => '* data:'); + + return $container; } } diff --git a/app/ServiceProvider/DatabaseProvider.php b/app/ServiceProvider/DatabaseProvider.php index b2115644..8cede8af 100644 --- a/app/ServiceProvider/DatabaseProvider.php +++ b/app/ServiceProvider/DatabaseProvider.php @@ -15,6 +15,8 @@ class DatabaseProvider implements ServiceProviderInterface $container['db'] = $this->getInstance(); $container['db']->stopwatch = DEBUG; $container['db']->logQueries = DEBUG; + + return $container; } /** diff --git a/app/ServiceProvider/EventDispatcherProvider.php b/app/ServiceProvider/EventDispatcherProvider.php index 1711919e..17141fd4 100644 --- a/app/ServiceProvider/EventDispatcherProvider.php +++ b/app/ServiceProvider/EventDispatcherProvider.php @@ -32,5 +32,7 @@ class EventDispatcherProvider implements ServiceProviderInterface // Automatic actions $container['action']->attachEvents(); + + return $container; } } diff --git a/app/ServiceProvider/LoggingProvider.php b/app/ServiceProvider/LoggingProvider.php index 4344bccc..68c074f0 100644 --- a/app/ServiceProvider/LoggingProvider.php +++ b/app/ServiceProvider/LoggingProvider.php @@ -26,5 +26,7 @@ class LoggingProvider implements ServiceProviderInterface } $container['logger'] = $logger; + + return $container; } } diff --git a/app/ServiceProvider/SessionProvider.php b/app/ServiceProvider/SessionProvider.php new file mode 100644 index 00000000..414d9578 --- /dev/null +++ b/app/ServiceProvider/SessionProvider.php @@ -0,0 +1,29 @@ +register(new Kanboard\ServiceProvider\SessionProvider); $container->register(new Kanboard\ServiceProvider\LoggingProvider); $container->register(new Kanboard\ServiceProvider\DatabaseProvider); $container->register(new Kanboard\ServiceProvider\ClassProvider); diff --git a/tests/units/Action/TaskAssignCurrentUserTest.php b/tests/units/Action/TaskAssignCurrentUserTest.php index f8946577..08176b1c 100644 --- a/tests/units/Action/TaskAssignCurrentUserTest.php +++ b/tests/units/Action/TaskAssignCurrentUserTest.php @@ -43,19 +43,16 @@ class TaskAssignCurrentUserTest extends Base public function testExecute() { + $this->container['sessionStorage']->user = array('id' => 5); + $action = new TaskAssignCurrentUser($this->container, 1, Task::EVENT_MOVE_COLUMN); $action->setParam('column_id', 2); - $_SESSION = array( - 'user' => array('id' => 5) - ); // We create a task in the first column $tc = new TaskCreation($this->container); $tf = new TaskFinder($this->container); $p = new Project($this->container); - $us = new UserSession($this->container); - $this->assertEquals(5, $us->getId()); $this->assertEquals(1, $p->create(array('name' => 'test'))); $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1))); diff --git a/tests/units/Auth/LdapTest.php b/tests/units/Auth/LdapTest.php index 19e7d7e2..bf2cec35 100644 --- a/tests/units/Auth/LdapTest.php +++ b/tests/units/Auth/LdapTest.php @@ -402,7 +402,7 @@ class LdapTest extends \Base $this->container['userSession'] = $this ->getMockBuilder('\Kanboard\Model\UserSession') ->setConstructorArgs(array($this->container)) - ->setMethods(array('refresh')) + ->setMethods(array('initialize')) ->getMock(); $this->container['user'] = $this @@ -436,7 +436,7 @@ class LdapTest extends \Base $this->container['userSession'] ->expects($this->once()) - ->method('refresh'); + ->method('initialize'); $this->assertTrue($ldap->authenticate('user', 'password')); } @@ -446,7 +446,7 @@ class LdapTest extends \Base $this->container['userSession'] = $this ->getMockBuilder('\Kanboard\Model\UserSession') ->setConstructorArgs(array($this->container)) - ->setMethods(array('refresh')) + ->setMethods(array('initialize')) ->getMock(); $this->container['user'] = $this @@ -480,7 +480,7 @@ class LdapTest extends \Base $this->container['userSession'] ->expects($this->never()) - ->method('refresh'); + ->method('initialize'); $this->assertFalse($ldap->authenticate('user', 'password')); } @@ -492,7 +492,7 @@ class LdapTest extends \Base $this->container['userSession'] = $this ->getMockBuilder('\Kanboard\Model\UserSession') ->setConstructorArgs(array($this->container)) - ->setMethods(array('refresh')) + ->setMethods(array('initialize')) ->getMock(); $this->container['user'] = $this @@ -542,7 +542,7 @@ class LdapTest extends \Base $this->container['userSession'] ->expects($this->once()) - ->method('refresh'); + ->method('initialize'); $this->assertTrue($ldap->authenticate('user', 'password')); } @@ -554,7 +554,7 @@ class LdapTest extends \Base $this->container['userSession'] = $this ->getMockBuilder('\Kanboard\Model\UserSession') ->setConstructorArgs(array($this->container)) - ->setMethods(array('refresh')) + ->setMethods(array('initialize')) ->getMock(); $this->container['user'] = $this @@ -596,7 +596,7 @@ class LdapTest extends \Base $this->container['userSession'] ->expects($this->never()) - ->method('refresh'); + ->method('initialize'); $this->assertFalse($ldap->authenticate('user', 'password')); } diff --git a/tests/units/Base.php b/tests/units/Base.php index 8112c954..6d7a97a3 100644 --- a/tests/units/Base.php +++ b/tests/units/Base.php @@ -8,6 +8,8 @@ use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher; use Symfony\Component\Stopwatch\Stopwatch; use SimpleLogger\Logger; use SimpleLogger\File; +use Kanboard\Core\Session\FlashMessage; +use Kanboard\Core\Session\SessionStorage; class FakeHttpClient { @@ -93,6 +95,12 @@ abstract class Base extends PHPUnit_Framework_TestCase ->setConstructorArgs(array($this->container)) ->setMethods(array('getType', 'getSelectedTypes')) ->getMock(); + + $this->container['sessionStorage'] = new SessionStorage; + + $this->container['flash'] = function($c) { + return new FlashMessage($c); + }; } public function tearDown() diff --git a/tests/units/Core/Session/FlashMessageTest.php b/tests/units/Core/Session/FlashMessageTest.php new file mode 100644 index 00000000..25361343 --- /dev/null +++ b/tests/units/Core/Session/FlashMessageTest.php @@ -0,0 +1,23 @@ +container); + + $flash->success('my message'); + $this->assertEquals('my message', $flash->getMessage('success')); + $this->assertEmpty($flash->getMessage('success')); + + $flash->failure('my error message'); + $this->assertEquals('my error message', $flash->getMessage('failure')); + $this->assertEmpty($flash->getMessage('failure')); + + $this->assertEmpty($flash->getMessage('not found')); + } +} diff --git a/tests/units/Core/Session/SessionStorageTest.php b/tests/units/Core/Session/SessionStorageTest.php new file mode 100644 index 00000000..62495e5e --- /dev/null +++ b/tests/units/Core/Session/SessionStorageTest.php @@ -0,0 +1,38 @@ +something = array('a' => 'b'); + $this->assertEquals(array('a' => 'b'), $storage->something); + $this->assertTrue(isset($storage->something)); + $this->assertFalse(isset($storage->something->x)); + $this->assertFalse(isset($storage->notFound)); + $this->assertFalse(isset($storage->notFound->x)); + $this->assertFalse(isset($storage->notFound['x'])); + } + + public function testPersistentStorage() + { + $session = array('d' => 'e'); + + $storage = new SessionStorage(); + $storage->setStorage($session); + $storage->something = array('a' => 'b'); + + $this->assertEquals(array('a' => 'b'), $storage->something); + $this->assertEquals('e', $storage->d); + + $storage->something['a'] = 'c'; + $this->assertEquals('c', $storage->something['a']); + + $storage = null; + $this->assertEquals(array('something' => array('a' => 'c'), 'd' => 'e'), $session); + } +} diff --git a/tests/units/Helper/AppHelperTest.php b/tests/units/Helper/AppHelperTest.php index cbd8b8ab..0639b7aa 100644 --- a/tests/units/Helper/AppHelperTest.php +++ b/tests/units/Helper/AppHelperTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Core\Session; +use Kanboard\Core\Session\FlashMessage; use Kanboard\Helper\App; use Kanboard\Model\Config; @@ -23,15 +23,15 @@ class AppHelperTest extends Base public function testFlashMessage() { $h = new App($this->container); - $s = new Session; + $f = new FlashMessage($this->container); $this->assertEmpty($h->flashMessage()); - $s->flash('test & test'); + + $f->success('test & test'); $this->assertEquals('
test & test
', $h->flashMessage()); $this->assertEmpty($h->flashMessage()); - $this->assertEmpty($h->flashMessage()); - $s->flashError('test & test'); + $f->failure('test & test'); $this->assertEquals('
test & test
', $h->flashMessage()); $this->assertEmpty($h->flashMessage()); } diff --git a/tests/units/Helper/UserHelperTest.php b/tests/units/Helper/UserHelperTest.php index 4cc9fa65..eba977ee 100644 --- a/tests/units/Helper/UserHelperTest.php +++ b/tests/units/Helper/UserHelperTest.php @@ -6,7 +6,6 @@ use Kanboard\Helper\User; use Kanboard\Model\Project; use Kanboard\Model\ProjectPermission; use Kanboard\Model\User as UserModel; -use Kanboard\Core\Session; class UserHelperTest extends Base { @@ -24,7 +23,6 @@ class UserHelperTest extends Base $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new UserModel($this->container); - $session = new Session; // We create our user $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest'))); @@ -36,7 +34,7 @@ class UserHelperTest extends Base $this->assertFalse($pp->isManager(1, 2)); // We fake a session for him - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'id' => 2, 'is_admin' => false, 'is_project_admin' => true, @@ -51,7 +49,6 @@ class UserHelperTest extends Base $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new UserModel($this->container); - $session = new Session; // We create our user $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest'))); @@ -63,7 +60,7 @@ class UserHelperTest extends Base $this->assertFalse($pp->isManager(1, 2)); // We fake a session for him - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'id' => 2, 'is_admin' => false, 'is_project_admin' => false, @@ -78,7 +75,6 @@ class UserHelperTest extends Base $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new UserModel($this->container); - $session = new Session; // We create our user $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest'))); @@ -90,7 +86,7 @@ class UserHelperTest extends Base $this->assertTrue($pp->isManager(1, 2)); // We fake a session for him - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'id' => 2, 'is_admin' => false, 'is_project_admin' => false, @@ -105,7 +101,6 @@ class UserHelperTest extends Base $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new UserModel($this->container); - $session = new Session; // We create our user $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest'))); @@ -117,7 +112,7 @@ class UserHelperTest extends Base $this->assertFalse($pp->isManager(1, 2)); // We fake a session for him - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'id' => 2, 'is_admin' => false, 'is_project_admin' => true, @@ -132,7 +127,6 @@ class UserHelperTest extends Base $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new UserModel($this->container); - $session = new Session; // We create our user $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest'))); @@ -144,7 +138,7 @@ class UserHelperTest extends Base $this->assertFalse($pp->isManager(1, 2)); // We fake a session for him - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'id' => 2, 'is_admin' => false, 'is_project_admin' => false, @@ -159,7 +153,6 @@ class UserHelperTest extends Base $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new UserModel($this->container); - $session = new Session; // We create our user $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest'))); @@ -171,7 +164,7 @@ class UserHelperTest extends Base $this->assertTrue($pp->isManager(1, 2)); // We fake a session for him - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'id' => 2, 'is_admin' => false, 'is_project_admin' => false, diff --git a/tests/units/Model/AclTest.php b/tests/units/Model/AclTest.php index 28687a5c..afda446b 100644 --- a/tests/units/Model/AclTest.php +++ b/tests/units/Model/AclTest.php @@ -2,7 +2,6 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Core\Session; use Kanboard\Model\Acl; use Kanboard\Model\Project; use Kanboard\Model\ProjectPermission; @@ -86,8 +85,6 @@ class AclTest extends Base public function testPageAccessNoSession() { $acl = new Acl($this->container); - $session = new Session; - $session = array(); $this->assertFalse($acl->isAllowed('board', 'readonly')); $this->assertFalse($acl->isAllowed('task', 'show')); @@ -100,8 +97,7 @@ class AclTest extends Base public function testPageAccessEmptySession() { $acl = new Acl($this->container); - $session = new Session; - $session['user'] = array(); + $this->container['sessionStorage']->user = array(); $this->assertFalse($acl->isAllowed('board', 'readonly')); $this->assertFalse($acl->isAllowed('task', 'show')); @@ -114,9 +110,7 @@ class AclTest extends Base public function testPageAccessAdminUser() { $acl = new Acl($this->container); - $session = new Session; - - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'is_admin' => true, ); @@ -140,7 +134,6 @@ class AclTest extends Base $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new User($this->container); - $session = new Session; // We create our user $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest'))); @@ -152,7 +145,7 @@ class AclTest extends Base $this->assertFalse($pp->isManager(1, 2)); // We fake a session for him - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'id' => 2, 'is_admin' => false, 'is_project_admin' => true, @@ -184,7 +177,6 @@ class AclTest extends Base $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new User($this->container); - $session = new Session; // We create our user $this->assertEquals(2, $u->create(array('username' => 'unittest', 'password' => 'unittest'))); @@ -195,7 +187,7 @@ class AclTest extends Base $this->assertTrue($pp->isManager(1, 2)); // We fake a session for him - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'id' => 2, 'is_admin' => false, ); @@ -237,9 +229,7 @@ class AclTest extends Base $this->assertTrue($pp->isMember(1, 2)); $this->assertFalse($pp->isManager(1, 2)); - $session = new Session; - - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'id' => 2, 'is_admin' => false, ); @@ -276,9 +266,7 @@ class AclTest extends Base $this->assertFalse($pp->isMember(1, 2)); $this->assertFalse($pp->isManager(1, 2)); - $session = new Session; - - $session['user'] = array( + $this->container['sessionStorage']->user = array( 'id' => 2, 'is_admin' => false, ); diff --git a/tests/units/Model/ConfigTest.php b/tests/units/Model/ConfigTest.php index 17617ceb..0c108fd1 100644 --- a/tests/units/Model/ConfigTest.php +++ b/tests/units/Model/ConfigTest.php @@ -3,7 +3,7 @@ require_once __DIR__.'/../Base.php'; use Kanboard\Model\Config; -use Kanboard\Core\Session; +use Kanboard\Core\Session\SessionManager; class ConfigTest extends Base { @@ -73,17 +73,15 @@ class ConfigTest extends Base public function testGetWithSession() { - $this->container['session'] = new Session; $c = new Config($this->container); session_id('test'); - - $this->assertTrue(Session::isOpen()); + $this->assertTrue(SessionManager::isOpen()); $this->assertEquals('', $c->get('board_columns')); $this->assertEquals('test', $c->get('board_columns', 'test')); - $this->container['session']['config'] = array( + $this->container['sessionStorage']->config = array( 'board_columns' => 'foo', 'empty_value' => 0 ); @@ -93,6 +91,6 @@ class ConfigTest extends Base $this->assertEquals('test', $c->get('empty_value', 'test')); session_id(''); - unset($this->container['session']); + $this->assertFalse(SessionManager::isOpen()); } } diff --git a/tests/units/Model/SubtaskTest.php b/tests/units/Model/SubtaskTest.php index 04b274cc..e446e104 100644 --- a/tests/units/Model/SubtaskTest.php +++ b/tests/units/Model/SubtaskTest.php @@ -8,7 +8,6 @@ use Kanboard\Model\Subtask; use Kanboard\Model\Project; use Kanboard\Model\Category; use Kanboard\Model\User; -use Kanboard\Core\Session; use Kanboard\Model\UserSession; class SubtaskTest extends Base @@ -190,7 +189,6 @@ class SubtaskTest extends Base $tc = new TaskCreation($this->container); $s = new Subtask($this->container); $p = new Project($this->container); - $ss = new Session; $us = new UserSession($this->container); $this->assertEquals(1, $p->create(array('name' => 'test1'))); @@ -205,7 +203,7 @@ class SubtaskTest extends Base $this->assertEquals(1, $subtask['task_id']); // Set the current logged user - $ss['user'] = array('id' => 1); + $this->container['sessionStorage']->user = array('id' => 1); $this->assertTrue($s->toggleStatus(1)); diff --git a/tests/units/Model/SubtaskTimeTrackingTest.php b/tests/units/Model/SubtaskTimeTrackingTest.php index 309be64a..40461eea 100644 --- a/tests/units/Model/SubtaskTimeTrackingTest.php +++ b/tests/units/Model/SubtaskTimeTrackingTest.php @@ -9,7 +9,6 @@ use Kanboard\Model\SubtaskTimeTracking; use Kanboard\Model\Project; use Kanboard\Model\Category; use Kanboard\Model\User; -use Kanboard\Core\Session; class SubtaskTimeTrackingTest extends Base { @@ -38,9 +37,8 @@ class SubtaskTimeTrackingTest extends Base $s = new Subtask($this->container); $st = new SubtaskTimeTracking($this->container); $p = new Project($this->container); - $ss = new Session; - $ss['user'] = array('id' => 1); + $this->container['sessionStorage']->user = array('id' => 1); $this->assertEquals(1, $p->create(array('name' => 'test1'))); $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); diff --git a/tests/units/Model/TaskCreationTest.php b/tests/units/Model/TaskCreationTest.php index d76937b2..5de0a5cc 100644 --- a/tests/units/Model/TaskCreationTest.php +++ b/tests/units/Model/TaskCreationTest.php @@ -182,8 +182,7 @@ class TaskCreationTest extends Base $tc = new TaskCreation($this->container); $tf = new TaskFinder($this->container); - $_SESSION = array(); - $_SESSION['user']['id'] = 1; + $this->container['sessionStorage']->user = array('id' => 1); $this->assertEquals(1, $p->create(array('name' => 'test'))); $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); @@ -194,8 +193,6 @@ class TaskCreationTest extends Base $this->assertEquals(1, $task['id']); $this->assertEquals(1, $task['creator_id']); - - $_SESSION = array(); } public function testColumnId() diff --git a/tests/units/Model/TaskDuplicationTest.php b/tests/units/Model/TaskDuplicationTest.php index 5273928c..d65e8f28 100644 --- a/tests/units/Model/TaskDuplicationTest.php +++ b/tests/units/Model/TaskDuplicationTest.php @@ -31,8 +31,7 @@ class TaskDuplicationTest extends Base $this->assertEquals(1, $task['project_id']); $this->assertEquals(0, $task['creator_id']); - $_SESSION = array(); - $_SESSION['user']['id'] = 1; + $this->container['sessionStorage']->user = array('id' => 1); // We duplicate our task $this->assertEquals(2, $td->duplicate(1)); @@ -41,8 +40,6 @@ class TaskDuplicationTest extends Base $task = $tf->getById(2); $this->assertNotEmpty($task); $this->assertEquals(1, $task['creator_id']); - - $_SESSION = array(); } public function testDuplicateSameProject() diff --git a/tests/units/Model/TaskPermissionTest.php b/tests/units/Model/TaskPermissionTest.php index 52a36549..56886ee7 100644 --- a/tests/units/Model/TaskPermissionTest.php +++ b/tests/units/Model/TaskPermissionTest.php @@ -33,7 +33,7 @@ class TaskPermissionTest extends Base // User #1 can remove everything $user = $u->getbyId(1); $this->assertNotEmpty($user); - $us->refresh($user); + $us->initialize($user); $task = $tf->getbyId(1); $this->assertNotEmpty($task); @@ -42,7 +42,7 @@ class TaskPermissionTest extends Base // User #2 can't remove the task #1 $user = $u->getbyId(2); $this->assertNotEmpty($user); - $us->refresh($user); + $us->initialize($user); $task = $tf->getbyId(1); $this->assertNotEmpty($task); @@ -51,7 +51,7 @@ class TaskPermissionTest extends Base // User #1 can remove everything $user = $u->getbyId(1); $this->assertNotEmpty($user); - $us->refresh($user); + $us->initialize($user); $task = $tf->getbyId(2); $this->assertNotEmpty($task); @@ -60,7 +60,7 @@ class TaskPermissionTest extends Base // User #2 can remove his own task $user = $u->getbyId(2); $this->assertNotEmpty($user); - $us->refresh($user); + $us->initialize($user); $task = $tf->getbyId(2); $this->assertNotEmpty($task); @@ -69,7 +69,7 @@ class TaskPermissionTest extends Base // User #1 can remove everything $user = $u->getbyId(1); $this->assertNotEmpty($user); - $us->refresh($user); + $us->initialize($user); $task = $tf->getbyId(3); $this->assertNotEmpty($task); @@ -78,7 +78,7 @@ class TaskPermissionTest extends Base // User #2 can't remove the task #3 $user = $u->getbyId(2); $this->assertNotEmpty($user); - $us->refresh($user); + $us->initialize($user); $task = $tf->getbyId(3); $this->assertNotEmpty($task); @@ -87,7 +87,7 @@ class TaskPermissionTest extends Base // User #1 can remove everything $user = $u->getbyId(1); $this->assertNotEmpty($user); - $us->refresh($user); + $us->initialize($user); $task = $tf->getbyId(4); $this->assertNotEmpty($task); @@ -96,7 +96,7 @@ class TaskPermissionTest extends Base // User #2 can't remove the task #4 $user = $u->getbyId(2); $this->assertNotEmpty($user); - $us->refresh($user); + $us->initialize($user); $task = $tf->getbyId(4); $this->assertNotEmpty($task); diff --git a/tests/units/Model/UserSessionTest.php b/tests/units/Model/UserSessionTest.php index 66f6faa7..ba1f8aac 100644 --- a/tests/units/Model/UserSessionTest.php +++ b/tests/units/Model/UserSessionTest.php @@ -2,31 +2,157 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Core\Session; use Kanboard\Model\UserSession; class UserSessionTest extends Base { - public function testIsAdmin() + public function testInitialize() { - $s = new Session; $us = new UserSession($this->container); - $this->assertFalse($us->isAdmin()); + $user = array( + 'id' => '123', + 'username' => 'john', + 'password' => 'something', + 'twofactor_secret' => 'something else', + 'is_admin' => '1', + 'is_project_admin' => '0', + 'is_ldap_user' => '0', + 'twofactor_activated' => '0', + ); + + $us->initialize($user); + + $session = $this->container['sessionStorage']->getAll(); + + $this->assertNotEmpty($session); + $this->assertEquals(123, $session['user']['id']); + $this->assertEquals('john', $session['user']['username']); + $this->assertTrue($session['user']['is_admin']); + $this->assertFalse($session['user']['is_project_admin']); + $this->assertFalse($session['user']['is_ldap_user']); + $this->assertFalse($session['user']['twofactor_activated']); + $this->assertArrayNotHasKey('password', $session['user']); + $this->assertArrayNotHasKey('twofactor_secret', $session['user']); + + $this->assertEquals('john', $us->getUsername()); + } + + public function testGetId() + { + $us = new UserSession($this->container); + + $this->assertEquals(0, $us->getId()); + + $this->container['sessionStorage']->user = array('id' => 2); + $this->assertEquals(2, $us->getId()); + + $this->container['sessionStorage']->user = array('id' => '2'); + $this->assertEquals(2, $us->getId()); + } + + public function testIsLogged() + { + $us = new UserSession($this->container); + + $this->assertFalse($us->isLogged()); + + $this->container['sessionStorage']->user = array(); + $this->assertFalse($us->isLogged()); + + $this->container['sessionStorage']->user = array('id' => 1); + $this->assertTrue($us->isLogged()); + } + + public function testIsAdmin() + { + $us = new UserSession($this->container); - $s['user'] = array(); $this->assertFalse($us->isAdmin()); - $s['user'] = array('is_admin' => '1'); + $this->container['sessionStorage']->user = array('is_admin' => '1'); $this->assertFalse($us->isAdmin()); - $s['user'] = array('is_admin' => false); + $this->container['sessionStorage']->user = array('is_admin' => '2'); $this->assertFalse($us->isAdmin()); - $s['user'] = array('is_admin' => '2'); + $this->container['sessionStorage']->user = array('is_admin' => false); $this->assertFalse($us->isAdmin()); - $s['user'] = array('is_admin' => true); + $this->container['sessionStorage']->user = array('is_admin' => true); $this->assertTrue($us->isAdmin()); } + + public function testIsProjectAdmin() + { + $us = new UserSession($this->container); + + $this->assertFalse($us->isProjectAdmin()); + + $this->container['sessionStorage']->user = array('is_project_admin' => false); + $this->assertFalse($us->isProjectAdmin()); + + $this->container['sessionStorage']->user = array('is_project_admin' => true); + $this->assertTrue($us->isProjectAdmin()); + } + + public function testCommentSorting() + { + $us = new UserSession($this->container); + $this->assertEquals('ASC', $us->getCommentSorting()); + + $us->setCommentSorting('DESC'); + $this->assertEquals('DESC', $us->getCommentSorting()); + } + + public function testBoardCollapseMode() + { + $us = new UserSession($this->container); + $this->assertFalse($us->isBoardCollapsed(2)); + + $us->setBoardDisplayMode(3, false); + $this->assertFalse($us->isBoardCollapsed(3)); + + $us->setBoardDisplayMode(3, true); + $this->assertTrue($us->isBoardCollapsed(3)); + } + + public function testFilters() + { + $us = new UserSession($this->container); + $this->assertEquals('status:open', $us->getFilters(1)); + + $us->setFilters(1, 'assignee:me'); + $this->assertEquals('assignee:me', $us->getFilters(1)); + + $this->assertEquals('status:open', $us->getFilters(2)); + + $us->setFilters(2, 'assignee:bob'); + $this->assertEquals('assignee:bob', $us->getFilters(2)); + } + + public function test2FA() + { + $us = new UserSession($this->container); + + $this->assertFalse($us->check2FA()); + + $this->container['sessionStorage']->postAuth = array('validated' => false); + $this->assertFalse($us->check2FA()); + + $this->container['sessionStorage']->postAuth = array('validated' => true); + $this->assertTrue($us->check2FA()); + + $this->container['sessionStorage']->user = array(); + $this->assertFalse($us->has2FA()); + + $this->container['sessionStorage']->user = array('twofactor_activated' => false); + $this->assertFalse($us->has2FA()); + + $this->container['sessionStorage']->user = array('twofactor_activated' => true); + $this->assertTrue($us->has2FA()); + + $us->disable2FA(); + $this->assertFalse($us->has2FA()); + } } -- cgit v1.2.3 From a42f1ea2bd0e208a67ac42a956f3a0bc366256de Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 15 Nov 2015 14:39:12 -0500 Subject: Add unit test for TaskAssignCategoryLink --- ChangeLog | 4 + app/Action/Base.php | 2 +- app/Action/TaskAssignCategoryLink.php | 10 ++- app/Locale/fr_FR/translations.php | 3 +- tests/units/Action/TaskAssignCategoryLinkTest.php | 97 +++++++++++++++++++++++ 5 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 tests/units/Action/TaskAssignCategoryLinkTest.php (limited to 'tests/units') diff --git a/ChangeLog b/ChangeLog index 32f89281..d48a18a5 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,6 +1,10 @@ Version 1.0.21 (unreleased) --------------------------- +New features: + +* New automatic action: Assign a category based on a link + Improvements: * Improve error handling of plugins diff --git a/app/Action/Base.php b/app/Action/Base.php index 4d2d6da6..81e2ccc6 100644 --- a/app/Action/Base.php +++ b/app/Action/Base.php @@ -241,7 +241,7 @@ abstract class Base extends \Kanboard\Core\Base } if (DEBUG) { - $this->container['logger']->debug(get_called_class().' => '.($result ? 'true' : 'false')); + $this->logger->debug(get_called_class().' => '.($result ? 'true' : 'false')); } return $result; diff --git a/app/Action/TaskAssignCategoryLink.php b/app/Action/TaskAssignCategoryLink.php index 8398facf..3d00e8d3 100644 --- a/app/Action/TaskAssignCategoryLink.php +++ b/app/Action/TaskAssignCategoryLink.php @@ -9,6 +9,7 @@ use Kanboard\Model\TaskLink; * * @package action * @author Olivier Maridat + * @author Frederic Guillot */ class TaskAssignCategoryLink extends Base { @@ -35,7 +36,7 @@ class TaskAssignCategoryLink extends Base { return array( 'category_id' => t('Category'), - 'link_id' => t('Link id'), + 'link_id' => t('Link type'), ); } @@ -79,6 +80,11 @@ class TaskAssignCategoryLink extends Base */ public function hasRequiredCondition(array $data) { - return $data['link_id'] == $this->getParam('link_id'); + if ($data['link_id'] == $this->getParam('link_id')) { + $task = $this->taskFinder->getById($data['task_id']); + return empty($task['category_id']); + } + + return false; } } diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php index 05bc5e04..2ef77f16 100644 --- a/app/Locale/fr_FR/translations.php +++ b/app/Locale/fr_FR/translations.php @@ -1066,6 +1066,7 @@ return array( 'Duplicates are not imported' => 'Les doublons ne sont pas importés', 'Usernames must be lowercase and unique' => 'Les noms d\'utilisateurs doivent être en minuscule et unique', 'Passwords will be encrypted if present' => 'Les mots de passe seront chiffrés si présent', - 'Assign automatically a category based on a color' => 'Assigner automatiquement une catégorie par rapport à un lien', '%s attached a new file to the task %s' => '%s a attaché un nouveau fichier à la tâche %s', + 'Link type' => 'Type de lien', + 'Assign automatically a category based on a link' => 'Assigner automatiquement une catégorie en fonction d\'un lien', ); diff --git a/tests/units/Action/TaskAssignCategoryLinkTest.php b/tests/units/Action/TaskAssignCategoryLinkTest.php new file mode 100644 index 00000000..4f2d757e --- /dev/null +++ b/tests/units/Action/TaskAssignCategoryLinkTest.php @@ -0,0 +1,97 @@ +container); + $tf = new TaskFinder($this->container); + $p = new Project($this->container); + $c = new Category($this->container); + + $action = new TaskAssignCategoryLink($this->container, 1, TaskLink::EVENT_CREATE_UPDATE); + $action->setParam('category_id', 1); + $action->setParam('link_id', 2); + + $this->assertEquals(1, $p->create(array('name' => 'P1'))); + $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1))); + $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1))); + + $task = $tf->getById(1); + $this->assertEquals(0, $task['category_id']); + + $event = array( + 'project_id' => 1, + 'task_id' => 1, + 'opposite_task_id' => 2, + 'link_id' => 2, + ); + + $this->assertTrue($action->execute(new TaskLinkEvent($event))); + + $task = $tf->getById(1); + $this->assertEquals(1, $task['category_id']); + } + + public function testThatLinkDontMatch() + { + $tc = new TaskCreation($this->container); + $tf = new TaskFinder($this->container); + $p = new Project($this->container); + $c = new Category($this->container); + + $action = new TaskAssignCategoryLink($this->container, 1, TaskLink::EVENT_CREATE_UPDATE); + $action->setParam('category_id', 1); + $action->setParam('link_id', 1); + + $this->assertEquals(1, $p->create(array('name' => 'P1'))); + $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1))); + $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1))); + + $event = array( + 'project_id' => 1, + 'task_id' => 1, + 'opposite_task_id' => 2, + 'link_id' => 2, + ); + + $this->assertFalse($action->execute(new TaskLinkEvent($event))); + } + + public function testThatExistingCategoryWillNotChange() + { + $tc = new TaskCreation($this->container); + $tf = new TaskFinder($this->container); + $p = new Project($this->container); + $c = new Category($this->container); + + $action = new TaskAssignCategoryLink($this->container, 1, TaskLink::EVENT_CREATE_UPDATE); + $action->setParam('category_id', 2); + $action->setParam('link_id', 2); + + $this->assertEquals(1, $p->create(array('name' => 'P1'))); + $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1))); + $this->assertEquals(2, $c->create(array('name' => 'C2', 'project_id' => 1))); + $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1, 'category_id' => 1))); + + $event = array( + 'project_id' => 1, + 'task_id' => 1, + 'opposite_task_id' => 2, + 'link_id' => 2, + ); + + $this->assertFalse($action->execute(new TaskLinkEvent($event))); + } +} -- cgit v1.2.3 From adb35896d8c5bcc6673188921868e472ba35278e Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 15 Nov 2015 19:29:31 -0500 Subject: Projects with duplicate name are now allowed --- ChangeLog | 12 +++++++++--- app/Locale/cs_CZ/translations.php | 2 +- app/Locale/da_DK/translations.php | 2 +- app/Locale/de_DE/translations.php | 2 +- app/Locale/es_ES/translations.php | 2 +- app/Locale/fi_FI/translations.php | 2 +- app/Locale/fr_FR/translations.php | 1 - app/Locale/hu_HU/translations.php | 2 +- app/Locale/id_ID/translations.php | 2 +- app/Locale/it_IT/translations.php | 2 +- app/Locale/ja_JP/translations.php | 2 +- app/Locale/nb_NO/translations.php | 2 +- app/Locale/nl_NL/translations.php | 2 +- app/Locale/pl_PL/translations.php | 2 +- app/Locale/pt_BR/translations.php | 2 +- app/Locale/pt_PT/translations.php | 2 +- app/Locale/ru_RU/translations.php | 2 +- app/Locale/sr_Latn_RS/translations.php | 2 +- app/Locale/sv_SE/translations.php | 2 +- app/Locale/th_TH/translations.php | 2 +- app/Locale/tr_TR/translations.php | 2 +- app/Locale/zh_CN/translations.php | 2 +- app/Model/Project.php | 1 - app/Schema/Mysql.php | 8 +++++++- app/Schema/Postgres.php | 7 ++++++- app/Schema/Sql/mysql.sql | 8 +++----- app/Schema/Sql/postgres.sql | 14 +++----------- app/Schema/Sqlite.php | 2 +- tests/units/Model/ProjectTest.php | 8 ++++++++ 29 files changed, 57 insertions(+), 44 deletions(-) (limited to 'tests/units') diff --git a/ChangeLog b/ChangeLog index 87142bc4..b3490ff7 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,6 +1,12 @@ Version 1.0.21 (unreleased) --------------------------- +Breaking changes: + +* Projects with duplicate name are now allowed: + For Postgres and Mysql the unique constraint is removed by database migration + However Sqlite does not support alter table, only new databases will have the unique constraint removed + New features: * New automatic action: Assign a category based on a link @@ -28,9 +34,9 @@ Version 1.0.20 Breaking changes: -- Add namespace Kanboard (update your plugins) -- Move Mailgun, Sendgrid, Postmark, Slack, Hipchat and Jabber to plugins -- ReverseProxy authentication check for each request that the username match the user session +* Add namespace Kanboard (update your plugins) +* Move Mailgun, Sendgrid, Postmark, Slack, Hipchat and Jabber to plugins +* ReverseProxy authentication check for each request that the username match the user session New features: diff --git a/app/Locale/cs_CZ/translations.php b/app/Locale/cs_CZ/translations.php index 647af0cc..0134acc4 100644 --- a/app/Locale/cs_CZ/translations.php +++ b/app/Locale/cs_CZ/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'ID je vyžadováno', 'The project id is required' => 'ID projektu je vyžadováno', 'The project name is required' => 'Jméno projektu je vyžadováno', - 'This project must be unique' => 'Jméno projektu musí být jedinečné', 'The title is required' => 'Nadpis je vyžadován', 'Settings saved successfully.' => 'Nastavení bylo úspěšně uloženo', 'Unable to save your settings.' => 'Vaše nastavení nelze uložit.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/da_DK/translations.php b/app/Locale/da_DK/translations.php index 0e886604..f36c08a2 100644 --- a/app/Locale/da_DK/translations.php +++ b/app/Locale/da_DK/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'Id\'et er krævet', 'The project id is required' => 'Projektets id er krævet', 'The project name is required' => 'Projektets navn er krævet', - 'This project must be unique' => 'Projektets navn skal være unikt', 'The title is required' => 'Titel er krævet', 'Settings saved successfully.' => 'Indstillinger gemt.', 'Unable to save your settings.' => 'Indstillinger kunne ikke gemmes.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/de_DE/translations.php b/app/Locale/de_DE/translations.php index 631dc449..03eb387e 100644 --- a/app/Locale/de_DE/translations.php +++ b/app/Locale/de_DE/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'Die ID ist anzugeben', 'The project id is required' => 'Die Projekt ID ist anzugeben', 'The project name is required' => 'Der Projektname ist anzugeben', - 'This project must be unique' => 'Der Projektname muss eindeutig sein', 'The title is required' => 'Der Titel ist anzugeben', 'Settings saved successfully.' => 'Einstellungen erfolgreich gespeichert.', 'Unable to save your settings.' => 'Speichern der Einstellungen nicht möglich.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php index 7b999882..e491df6c 100644 --- a/app/Locale/es_ES/translations.php +++ b/app/Locale/es_ES/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'El identificador es obligatorio', 'The project id is required' => 'El identificador del proyecto es obligatorio', 'The project name is required' => 'El nombre del proyecto es obligatorio', - 'This project must be unique' => 'El nombre del proyecto debe ser único', 'The title is required' => 'El título es obligatorio', 'Settings saved successfully.' => 'Parámetros guardados correctamente.', 'Unable to save your settings.' => 'No se pueden guardar sus parámetros.', @@ -1065,4 +1064,5 @@ return array( 'Usernames must be lowercase and unique' => 'Los nombres de usuario deben ser únicos y contener sólo minúsculas', 'Passwords will be encrypted if present' => 'Las contraseñas serán cifradas si es que existen', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/fi_FI/translations.php b/app/Locale/fi_FI/translations.php index 128b7aaa..15581c2e 100644 --- a/app/Locale/fi_FI/translations.php +++ b/app/Locale/fi_FI/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'ID vaaditaan', 'The project id is required' => 'Projektin ID on pakollinen', 'The project name is required' => 'Projektin nimi on pakollinen', - 'This project must be unique' => 'Projektin nimi täytyy olla uniikki', 'The title is required' => 'Otsikko vaaditaan', 'Settings saved successfully.' => 'Asetukset tallennettu onnistuneesti.', 'Unable to save your settings.' => 'Asetusten tallentaminen epäonnistui.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php index 2ef77f16..095f15c6 100644 --- a/app/Locale/fr_FR/translations.php +++ b/app/Locale/fr_FR/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'L\'identifiant est obligatoire', 'The project id is required' => 'L\'identifiant du projet est obligatoire', 'The project name is required' => 'Le nom du projet est obligatoire', - 'This project must be unique' => 'Le nom du projet doit être unique', 'The title is required' => 'Le titre est obligatoire', 'Settings saved successfully.' => 'Paramètres sauvegardés avec succès.', 'Unable to save your settings.' => 'Impossible de sauvegarder vos réglages.', diff --git a/app/Locale/hu_HU/translations.php b/app/Locale/hu_HU/translations.php index 85084bbc..050528aa 100644 --- a/app/Locale/hu_HU/translations.php +++ b/app/Locale/hu_HU/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'Az ID-t (azonosítót) meg kell adni', 'The project id is required' => 'A projekt ID-t (azonosítót) meg kell adni', 'The project name is required' => 'A projekt nevét meg kell adni', - 'This project must be unique' => 'A projekt nevének egyedinek kell lennie', 'The title is required' => 'A címet meg kell adni', 'Settings saved successfully.' => 'A beállítások sikeresen mentve.', 'Unable to save your settings.' => 'A beállítások mentése sikertelen.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/id_ID/translations.php b/app/Locale/id_ID/translations.php index 6bf69623..49444f10 100644 --- a/app/Locale/id_ID/translations.php +++ b/app/Locale/id_ID/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'Id diperlukan', 'The project id is required' => 'Id proyek diperlukan', 'The project name is required' => 'Nama proyek diperlukan', - 'This project must be unique' => 'Proyek ini harus unik', 'The title is required' => 'Judul diperlukan', 'Settings saved successfully.' => 'Pengaturan berhasil disimpan.', 'Unable to save your settings.' => 'Tidak dapat menyimpan pengaturan anda.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/it_IT/translations.php b/app/Locale/it_IT/translations.php index d2879fb0..aa842c36 100644 --- a/app/Locale/it_IT/translations.php +++ b/app/Locale/it_IT/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'Si richiede l\'identificatore', 'The project id is required' => 'Si richiede l\'identificatore del progetto', 'The project name is required' => 'Si richiede il nome del progetto', - 'This project must be unique' => 'Il nome del progetto deve essere unico', 'The title is required' => 'Si richiede un titolo', 'Settings saved successfully.' => 'Impostazioni salvate correttamente.', 'Unable to save your settings.' => 'Non si possono salvare le impostazioni.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/ja_JP/translations.php b/app/Locale/ja_JP/translations.php index bfe3952e..c77b3b11 100644 --- a/app/Locale/ja_JP/translations.php +++ b/app/Locale/ja_JP/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'ID が必要です', 'The project id is required' => 'プロジェクト ID が必要です', 'The project name is required' => 'プロジェクト名が必要です', - 'This project must be unique' => 'プロジェクト名がすでに使われています', 'The title is required' => 'タイトルが必要です', 'Settings saved successfully.' => '設定を保存しました。', 'Unable to save your settings.' => '設定の保存に失敗しました。', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/nb_NO/translations.php b/app/Locale/nb_NO/translations.php index eb064079..48cf482e 100644 --- a/app/Locale/nb_NO/translations.php +++ b/app/Locale/nb_NO/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'Id\'en er pøøkrevet', 'The project id is required' => 'Prosjektet-id er påkrevet', 'The project name is required' => 'Prosjektnavn er påkrevet', - 'This project must be unique' => 'Prosjektnavnet skal være unikt', 'The title is required' => 'Tittel er pårevet', 'Settings saved successfully.' => 'Innstillinger lagret.', 'Unable to save your settings.' => 'Innstillinger kunne ikke lagres.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/nl_NL/translations.php b/app/Locale/nl_NL/translations.php index bfda3aa4..9fbb95d7 100644 --- a/app/Locale/nl_NL/translations.php +++ b/app/Locale/nl_NL/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'Het id is verplicht', 'The project id is required' => 'Het project id is verplicht', 'The project name is required' => 'De projectnaam is verplicht', - 'This project must be unique' => 'Dit project moet uniek zijn', 'The title is required' => 'De titel is verplicht', 'Settings saved successfully.' => 'Instellingen succesvol opgeslagen.', 'Unable to save your settings.' => 'Instellingen opslaan niet gelukt.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/pl_PL/translations.php b/app/Locale/pl_PL/translations.php index 4a4823e4..063496c3 100644 --- a/app/Locale/pl_PL/translations.php +++ b/app/Locale/pl_PL/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'ID jest wymagane', 'The project id is required' => 'ID projektu jest wymagane', 'The project name is required' => 'Nazwa projektu jest wymagana', - 'This project must be unique' => 'Projekt musi być unikalny', 'The title is required' => 'Tutył jest wymagany', 'Settings saved successfully.' => 'Ustawienia zapisane.', 'Unable to save your settings.' => 'Nie udało się zapisać ustawień.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/pt_BR/translations.php b/app/Locale/pt_BR/translations.php index 3f40bfe9..023897e1 100644 --- a/app/Locale/pt_BR/translations.php +++ b/app/Locale/pt_BR/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'O ID é obrigatório', 'The project id is required' => 'O ID do projeto é obrigatório', 'The project name is required' => 'O nome do projeto é obrigatório', - 'This project must be unique' => 'Este projeto deve ser único', 'The title is required' => 'O título é obrigatório', 'Settings saved successfully.' => 'Configurações salvas com sucesso.', 'Unable to save your settings.' => 'Não é possível salvar suas configurações.', @@ -1065,4 +1064,5 @@ return array( 'Usernames must be lowercase and unique' => 'Nomes de usuário devem ser únicos e em letras minúsculas', 'Passwords will be encrypted if present' => 'Senhas serão encriptadas, se presentes', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/pt_PT/translations.php b/app/Locale/pt_PT/translations.php index 778f288b..93ba323a 100644 --- a/app/Locale/pt_PT/translations.php +++ b/app/Locale/pt_PT/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'O ID é obrigatório', 'The project id is required' => 'O ID do projecto é obrigatório', 'The project name is required' => 'O nome do projecto é obrigatório', - 'This project must be unique' => 'Este projecto deve ser único', 'The title is required' => 'O título é obrigatório', 'Settings saved successfully.' => 'Configurações guardadas com sucesso.', 'Unable to save your settings.' => 'Não é possível guardar as suas configurações.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php index 6e7e5428..cacdbfa3 100644 --- a/app/Locale/ru_RU/translations.php +++ b/app/Locale/ru_RU/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'Необходим ID', 'The project id is required' => 'Необходим ID проекта', 'The project name is required' => 'Необходимо имя проекта', - 'This project must be unique' => 'Проект должен быть уникальным', 'The title is required' => 'Необходим заголовок', 'Settings saved successfully.' => 'Параметры успешно сохранены.', 'Unable to save your settings.' => 'Невозможно сохранить параметры.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/sr_Latn_RS/translations.php b/app/Locale/sr_Latn_RS/translations.php index 223055e4..74b60f86 100644 --- a/app/Locale/sr_Latn_RS/translations.php +++ b/app/Locale/sr_Latn_RS/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'ID je obavezan', 'The project id is required' => 'ID projekta je obavezan', 'The project name is required' => 'Naziv projekta je obavezan', - 'This project must be unique' => 'Projekat mora biti jedinstven', 'The title is required' => 'Naslov je obavezan', 'Settings saved successfully.' => 'Podešavanja uspešno snimljena.', 'Unable to save your settings.' => 'Nemoguće snimanje podešavanja.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php index 3c38621c..9587538f 100644 --- a/app/Locale/sv_SE/translations.php +++ b/app/Locale/sv_SE/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'Aktuellt ID måste anges', 'The project id is required' => 'Projekt-ID måste anges', 'The project name is required' => 'Ett projektnamn måste anges', - 'This project must be unique' => 'Detta projekt måste vara unikt', 'The title is required' => 'En titel måste anges.', 'Settings saved successfully.' => 'Inställningarna har sparats.', 'Unable to save your settings.' => 'Kunde inte spara dina ändringar', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php index 6603eadf..77affbc5 100644 --- a/app/Locale/th_TH/translations.php +++ b/app/Locale/th_TH/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'ต้องการไอดี', 'The project id is required' => 'ต้องการไอดีโปรเจค', 'The project name is required' => 'ต้องการชื่อโปรเจค', - 'This project must be unique' => 'ชื่อโปรเจคต้องไม่ซ้ำ', 'The title is required' => 'ต้องการหัวเรื่อง', 'Settings saved successfully.' => 'บันทึกการตั้งค่าเรียบร้อยแล้ว', 'Unable to save your settings.' => 'ไม่สามารถบันทึกการตั้งค่าได้', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/tr_TR/translations.php b/app/Locale/tr_TR/translations.php index c3f724df..d92b768c 100644 --- a/app/Locale/tr_TR/translations.php +++ b/app/Locale/tr_TR/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => 'Kod gerekli', 'The project id is required' => 'Proje kodu gerekli', 'The project name is required' => 'Proje adı gerekli', - 'This project must be unique' => 'Bu projenin tekil olması gerekli', 'The title is required' => 'Başlık gerekli', 'Settings saved successfully.' => 'Ayarlar başarıyla kaydedildi.', 'Unable to save your settings.' => 'Ayarlarınız kaydedilemedi.', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Locale/zh_CN/translations.php b/app/Locale/zh_CN/translations.php index d8ac66d0..a3d93baf 100644 --- a/app/Locale/zh_CN/translations.php +++ b/app/Locale/zh_CN/translations.php @@ -124,7 +124,6 @@ return array( 'The id is required' => '需要指定id', 'The project id is required' => '需要指定项目id', 'The project name is required' => '需要指定项目名称', - 'This project must be unique' => '项目名称必须唯一', 'The title is required' => '需要指定标题', 'Settings saved successfully.' => '设置成功保存。', 'Unable to save your settings.' => '无法保存你的设置。', @@ -1065,4 +1064,5 @@ return array( // 'Usernames must be lowercase and unique' => '', // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', ); diff --git a/app/Model/Project.php b/app/Model/Project.php index 9e30a9b8..a7f93099 100644 --- a/app/Model/Project.php +++ b/app/Model/Project.php @@ -527,7 +527,6 @@ class Project extends Base new Validators\MaxLength('start_date', t('The maximum length is %d characters', 10), 10), new Validators\MaxLength('end_date', t('The maximum length is %d characters', 10), 10), new Validators\AlphaNumeric('identifier', t('This value must be alphanumeric')) , - new Validators\Unique('name', t('This project must be unique'), $this->db->getConnection(), self::TABLE), new Validators\Unique('identifier', t('The identifier must be unique'), $this->db->getConnection(), self::TABLE), ); } diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php index 54d58592..52a73fb1 100644 --- a/app/Schema/Mysql.php +++ b/app/Schema/Mysql.php @@ -5,7 +5,13 @@ namespace Schema; use PDO; use Kanboard\Core\Security\Token; -const VERSION = 93; +const VERSION = 94; + +function version_94(PDO $pdo) +{ + $pdo->exec('ALTER TABLE `projects` DROP INDEX `name`'); + $pdo->exec('ALTER TABLE `projects` DROP INDEX `name_2`'); +} function version_93(PDO $pdo) { diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php index 6f7efed0..5cd1a7d0 100644 --- a/app/Schema/Postgres.php +++ b/app/Schema/Postgres.php @@ -5,7 +5,12 @@ namespace Schema; use PDO; use Kanboard\Core\Security\Token; -const VERSION = 73; +const VERSION = 74; + +function version_74(PDO $pdo) +{ + $pdo->exec('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_name_key'); +} function version_73(PDO $pdo) { diff --git a/app/Schema/Sql/mysql.sql b/app/Schema/Sql/mysql.sql index bb87a6ca..eb59cca1 100644 --- a/app/Schema/Sql/mysql.sql +++ b/app/Schema/Sql/mysql.sql @@ -270,9 +270,7 @@ CREATE TABLE `projects` ( `identifier` varchar(50) DEFAULT '', `start_date` varchar(10) DEFAULT '', `end_date` varchar(10) DEFAULT '', - PRIMARY KEY (`id`), - UNIQUE KEY `name` (`name`), - UNIQUE KEY `name_2` (`name`) + PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; DROP TABLE IF EXISTS `remember_me`; @@ -551,7 +549,7 @@ CREATE TABLE `users` ( LOCK TABLES `settings` WRITE; /*!40000 ALTER TABLE `settings` DISABLE KEYS */; -INSERT INTO `settings` VALUES ('api_token','ccff8d37146322410479c8c6707cdaddde840af28ccbd6fbb5a7d7908844'),('application_currency','USD'),('application_date_format','m/d/Y'),('application_language','en_US'),('application_stylesheet',''),('application_timezone','UTC'),('application_url',''),('board_columns',''),('board_highlight_period','172800'),('board_private_refresh_interval','10'),('board_public_refresh_interval','60'),('calendar_project_tasks','date_started'),('calendar_user_subtasks_time_tracking','0'),('calendar_user_tasks','date_started'),('cfd_include_closed_tasks','1'),('default_color','yellow'),('integration_gravatar','0'),('project_categories',''),('subtask_restriction','0'),('subtask_time_tracking','1'),('webhook_token','7a9d4cd8c7fc4d52c60f01b775d4f2bd6e186d2e44f5b3723157b8eb372b'),('webhook_url',''); +INSERT INTO `settings` VALUES ('api_token','3783f814662e83f1ebe9ada40314f93a6e75688fe7e04a3820e4078966f0'),('application_currency','USD'),('application_date_format','m/d/Y'),('application_language','en_US'),('application_stylesheet',''),('application_timezone','UTC'),('application_url',''),('board_columns',''),('board_highlight_period','172800'),('board_private_refresh_interval','10'),('board_public_refresh_interval','60'),('calendar_project_tasks','date_started'),('calendar_user_subtasks_time_tracking','0'),('calendar_user_tasks','date_started'),('cfd_include_closed_tasks','1'),('default_color','yellow'),('integration_gravatar','0'),('project_categories',''),('subtask_restriction','0'),('subtask_time_tracking','1'),('webhook_token','c95cf0a67507ca68cc93f717bb78ac5dfaf0c73d38ab159fc73038aa19d9'),('webhook_url',''); /*!40000 ALTER TABLE `settings` ENABLE KEYS */; UNLOCK TABLES; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; @@ -580,4 +578,4 @@ UNLOCK TABLES; /*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; -INSERT INTO users (username, password, is_admin) VALUES ('admin', '$2y$10$fDbO.nKAjDxm70DyghADCuqIhF919BAkRTAq0bARDTGwcxZscqIZq', '1');INSERT INTO schema_version VALUES ('93'); +INSERT INTO users (username, password, is_admin) VALUES ('admin', '$2y$10$4/2e1E1VIeZVc5PhRHQJmuOBI/UV7H73hRyH60IvpTpY05G9tD49W', '1');INSERT INTO schema_version VALUES ('94'); diff --git a/app/Schema/Sql/postgres.sql b/app/Schema/Sql/postgres.sql index 8738010f..f13863c8 100644 --- a/app/Schema/Sql/postgres.sql +++ b/app/Schema/Sql/postgres.sql @@ -1314,14 +1314,6 @@ ALTER TABLE ONLY project_has_users ADD CONSTRAINT project_has_users_project_id_user_id_key UNIQUE (project_id, user_id); --- --- Name: projects_name_key; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: --- - -ALTER TABLE ONLY projects - ADD CONSTRAINT projects_name_key UNIQUE (name); - - -- -- Name: projects_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: -- @@ -1930,8 +1922,8 @@ INSERT INTO settings (option, value) VALUES ('board_highlight_period', '172800') INSERT INTO settings (option, value) VALUES ('board_public_refresh_interval', '60'); INSERT INTO settings (option, value) VALUES ('board_private_refresh_interval', '10'); INSERT INTO settings (option, value) VALUES ('board_columns', ''); -INSERT INTO settings (option, value) VALUES ('webhook_token', '29877f0b69d230e57bee9d02e0aa9034a69f7a2c0ba1e3b5d3b390241f36'); -INSERT INTO settings (option, value) VALUES ('api_token', '5682955e965bd0cd7618559a25131fe6094d9fff3bb56c31291d64991353'); +INSERT INTO settings (option, value) VALUES ('webhook_token', 'ca57fbbc9e17d00a1ca8c2e45d8dc5d1b54ede740f72709bae2e8de26fbd'); +INSERT INTO settings (option, value) VALUES ('api_token', 'bc20677f12faa32d9426af9a31041b8576c13f4ab54b641bae955112ae79'); INSERT INTO settings (option, value) VALUES ('application_language', 'en_US'); INSERT INTO settings (option, value) VALUES ('application_timezone', 'UTC'); INSERT INTO settings (option, value) VALUES ('application_url', ''); @@ -1995,4 +1987,4 @@ SELECT pg_catalog.setval('links_id_seq', 11, true); -- PostgreSQL database dump complete -- -INSERT INTO users (username, password, is_admin) VALUES ('admin', '$2y$10$fDbO.nKAjDxm70DyghADCuqIhF919BAkRTAq0bARDTGwcxZscqIZq', '1');INSERT INTO schema_version VALUES ('73'); +INSERT INTO users (username, password, is_admin) VALUES ('admin', '$2y$10$4/2e1E1VIeZVc5PhRHQJmuOBI/UV7H73hRyH60IvpTpY05G9tD49W', '1');INSERT INTO schema_version VALUES ('74'); diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php index d27f11ec..fa26b158 100644 --- a/app/Schema/Sqlite.php +++ b/app/Schema/Sqlite.php @@ -1026,7 +1026,7 @@ function version_1(PDO $pdo) $pdo->exec(" CREATE TABLE projects ( id INTEGER PRIMARY KEY, - name TEXT NOCASE NOT NULL UNIQUE, + name TEXT NOCASE NOT NULL, is_active INTEGER DEFAULT 1 ) "); diff --git a/tests/units/Model/ProjectTest.php b/tests/units/Model/ProjectTest.php index f90c0dc1..56791700 100644 --- a/tests/units/Model/ProjectTest.php +++ b/tests/units/Model/ProjectTest.php @@ -44,6 +44,14 @@ class ProjectTest extends Base $this->assertEmpty($project['token']); } + public function testCreationWithDuplicateName() + { + $p = new Project($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'UnitTest'))); + $this->assertEquals(2, $p->create(array('name' => 'UnitTest'))); + } + public function testCreationWithStartAndDate() { $p = new Project($this->container); -- cgit v1.2.3 From f119cbd6be467b3832a4543045980dd0f1936275 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Fri, 20 Nov 2015 20:39:43 -0500 Subject: Make CSV export compatible with PHP 5.3 --- ChangeLog | 1 + app/Core/Csv.php | 13 ++++++++----- tests/units/Core/CsvTest.php | 30 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) (limited to 'tests/units') diff --git a/ChangeLog b/ChangeLog index 833350fc..2b62304a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -29,6 +29,7 @@ Bug fixes: * Fix PHP error when adding a new user with email notification enabled * Add missing template for activity stream to show event "file.create" * Fix wrong value for PLUGINS_DIR in config.default.php +* Make CSV export compatible with PHP 5.3 Version 1.0.20 -------------- diff --git a/app/Core/Csv.php b/app/Core/Csv.php index bec400ed..28c1997b 100644 --- a/app/Core/Csv.php +++ b/app/Core/Csv.php @@ -93,8 +93,7 @@ class Csv { if (! empty($value)) { $value = trim(strtolower($value)); - return $value === '1' || $value{0} - === 't' ? 1 : 0; + return $value === '1' || $value{0} === 't' ? 1 : 0; } return 0; @@ -164,10 +163,14 @@ class Csv */ public function write($filename, array $rows) { - $file = new SplFileObject($filename, 'w'); + $fp = fopen($filename, 'w'); - foreach ($rows as $row) { - $file->fputcsv($row, $this->delimiter, $this->enclosure); + if (is_resource($fp)) { + foreach ($rows as $row) { + fputcsv($fp, $row, $this->delimiter, $this->enclosure); + } + + fclose($fp); } return $this; diff --git a/tests/units/Core/CsvTest.php b/tests/units/Core/CsvTest.php index 71542c20..d34ccf76 100644 --- a/tests/units/Core/CsvTest.php +++ b/tests/units/Core/CsvTest.php @@ -19,4 +19,34 @@ class CsvTest extends Base $this->assertEquals(0, Csv::getBooleanValue('123')); $this->assertEquals(0, Csv::getBooleanValue('anything')); } + + public function testGetEnclosures() + { + $this->assertCount(3, Csv::getEnclosures()); + $this->assertCount(4, Csv::getDelimiters()); + } + + public function testReadWrite() + { + $filename = tempnam(sys_get_temp_dir(), 'UT'); + $rows = array( + array('Column A', 'Column B'), + array('value a', 'value b'), + ); + + $csv = new Csv; + $csv->write($filename, $rows); + $csv->setColumnMapping(array('A', 'B', 'C')); + $csv->read($filename, array($this, 'readRow')); + + unlink($filename); + + $this->expectOutputString('"Column A","Column B"'.PHP_EOL.'"value a","value b"'.PHP_EOL, $csv->output($rows)); + } + + public function readRow(array $row, $line) + { + $this->assertEquals(array('value a', 'value b', ''), $row); + $this->assertEquals(1, $line); + } } -- cgit v1.2.3 From 83e4c7729ed58e63f2be96431f5e0685d45b1593 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Fri, 20 Nov 2015 21:24:15 -0500 Subject: Fix unit test --- tests/units/Model/TaskExportTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests/units') diff --git a/tests/units/Model/TaskExportTest.php b/tests/units/Model/TaskExportTest.php index 40b3a5a2..b40b0771 100644 --- a/tests/units/Model/TaskExportTest.php +++ b/tests/units/Model/TaskExportTest.php @@ -51,7 +51,7 @@ class TaskExportTest extends Base $this->assertEquals($i, count($rows)); $this->assertEquals('Task Id', $rows[0][0]); $this->assertEquals(1, $rows[1][0]); - $this->assertEquals('Task #'.($i - 1), $rows[$i - 1][12]); + $this->assertEquals('Task #'.($i - 1), $rows[$i - 1][13]); $this->assertTrue(in_array($rows[$i - 1][4], array('Default swimlane', 'S1', 'S2'))); } } -- cgit v1.2.3 From 4594325726b45b8b538364100cea1d4e72a90bcb Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 21 Nov 2015 17:12:27 -0500 Subject: Make test case pass under Windows --- tests/units/Core/CsvTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests/units') diff --git a/tests/units/Core/CsvTest.php b/tests/units/Core/CsvTest.php index d34ccf76..7276de7f 100644 --- a/tests/units/Core/CsvTest.php +++ b/tests/units/Core/CsvTest.php @@ -41,7 +41,7 @@ class CsvTest extends Base unlink($filename); - $this->expectOutputString('"Column A","Column B"'.PHP_EOL.'"value a","value b"'.PHP_EOL, $csv->output($rows)); + $this->expectOutputString('"Column A","Column B"'."\n".'"value a","value b"'."\n", $csv->output($rows)); } public function readRow(array $row, $line) -- cgit v1.2.3 From 0a7370b9e5652918ad9386baa81cc12627069755 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 21 Nov 2015 18:41:15 -0500 Subject: Remove workaround for 'INSERT ON DUPLICATE KEY UPDATE...' --- ChangeLog | 1 + app/Model/ProjectDailyColumnStats.php | 63 +++++++++++++++++------------ app/Model/ProjectDailyStats.php | 32 +++++++++------ tests/units/Model/ProjectDailyStatsTest.php | 45 +++++++++++++++++++++ 4 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 tests/units/Model/ProjectDailyStatsTest.php (limited to 'tests/units') diff --git a/ChangeLog b/ChangeLog index 92658cb7..acd09fdd 100644 --- a/ChangeLog +++ b/ChangeLog @@ -18,6 +18,7 @@ Improvements: * Use PHP7 function random_bytes() to generate tokens if available * CSV task export show the assignee name in addition to the assignee username * Add new hooks for plugins +* Remove workaround for "INSERT ON DUPLICATE KEY UPDATE..." Internal code refactoring: diff --git a/app/Model/ProjectDailyColumnStats.php b/app/Model/ProjectDailyColumnStats.php index 8ed6137f..4b75fff2 100644 --- a/app/Model/ProjectDailyColumnStats.php +++ b/app/Model/ProjectDailyColumnStats.php @@ -34,40 +34,51 @@ class ProjectDailyColumnStats extends Base { $status = $this->config->get('cfd_include_closed_tasks') == 1 ? array(Task::STATUS_OPEN, Task::STATUS_CLOSED) : array(Task::STATUS_OPEN); - return $this->db->transaction(function (Database $db) use ($project_id, $date, $status) { + $this->db->startTransaction(); - $column_ids = $db->table(Board::TABLE)->eq('project_id', $project_id)->findAllByColumn('id'); + $column_ids = $this->db->table(Board::TABLE)->eq('project_id', $project_id)->findAllByColumn('id'); - foreach ($column_ids as $column_id) { + foreach ($column_ids as $column_id) { - // This call will fail if the record already exists - // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE) - $db->table(ProjectDailyColumnStats::TABLE)->insert(array( - 'day' => $date, - 'project_id' => $project_id, - 'column_id' => $column_id, - 'total' => 0, - 'score' => 0, - )); + $exists = $this->db->table(ProjectDailyColumnStats::TABLE) + ->eq('project_id', $project_id) + ->eq('column_id', $column_id) + ->eq('day', $date) + ->exists(); + + $score = $this->db->table(Task::TABLE) + ->eq('project_id', $project_id) + ->eq('column_id', $column_id) + ->eq('is_active', Task::STATUS_OPEN) + ->sum('score'); - $db->table(ProjectDailyColumnStats::TABLE) + $total = $this->db->table(Task::TABLE) + ->eq('project_id', $project_id) + ->eq('column_id', $column_id) + ->in('is_active', $status) + ->count(); + + if ($exists) { + $this->db->table(ProjectDailyColumnStats::TABLE) ->eq('project_id', $project_id) ->eq('column_id', $column_id) ->eq('day', $date) - ->update(array( - 'score' => $db->table(Task::TABLE) - ->eq('project_id', $project_id) - ->eq('column_id', $column_id) - ->eq('is_active', Task::STATUS_OPEN) - ->sum('score'), - 'total' => $db->table(Task::TABLE) - ->eq('project_id', $project_id) - ->eq('column_id', $column_id) - ->in('is_active', $status) - ->count() - )); + ->update(array('score' => $score, 'total' => $total)); + + } else { + $this->db->table(ProjectDailyColumnStats::TABLE)->insert(array( + 'day' => $date, + 'project_id' => $project_id, + 'column_id' => $column_id, + 'total' => $total, + 'score' => $score, + )); } - }); + } + + $this->db->closeTransaction(); + + return true; } /** diff --git a/app/Model/ProjectDailyStats.php b/app/Model/ProjectDailyStats.php index 46ca0a4b..7ec1ee2c 100644 --- a/app/Model/ProjectDailyStats.php +++ b/app/Model/ProjectDailyStats.php @@ -29,27 +29,35 @@ class ProjectDailyStats extends Base */ public function updateTotals($project_id, $date) { - $lead_cycle_time = $this->projectAnalytic->getAverageLeadAndCycleTime($project_id); + $this->db->startTransaction(); - return $this->db->transaction(function (Database $db) use ($project_id, $date, $lead_cycle_time) { + $lead_cycle_time = $this->projectAnalytic->getAverageLeadAndCycleTime($project_id); - // This call will fail if the record already exists - // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE) - $db->table(ProjectDailyStats::TABLE)->insert(array( - 'day' => $date, - 'project_id' => $project_id, - 'avg_lead_time' => 0, - 'avg_cycle_time' => 0, - )); + $exists = $this->db->table(ProjectDailyStats::TABLE) + ->eq('day', $date) + ->eq('project_id', $project_id) + ->exists(); - $db->table(ProjectDailyStats::TABLE) + if ($exists) { + $this->db->table(ProjectDailyStats::TABLE) ->eq('project_id', $project_id) ->eq('day', $date) ->update(array( 'avg_lead_time' => $lead_cycle_time['avg_lead_time'], 'avg_cycle_time' => $lead_cycle_time['avg_cycle_time'], )); - }); + } else { + $this->db->table(ProjectDailyStats::TABLE)->insert(array( + 'day' => $date, + 'project_id' => $project_id, + 'avg_lead_time' => $lead_cycle_time['avg_lead_time'], + 'avg_cycle_time' => $lead_cycle_time['avg_cycle_time'], + )); + } + + $this->db->closeTransaction(); + + return true; } /** diff --git a/tests/units/Model/ProjectDailyStatsTest.php b/tests/units/Model/ProjectDailyStatsTest.php new file mode 100644 index 00000000..c17017c9 --- /dev/null +++ b/tests/units/Model/ProjectDailyStatsTest.php @@ -0,0 +1,45 @@ +container); + $pds = new ProjectDailyStats($this->container); + $tc = new TaskCreation($this->container); + $ts = new TaskStatus($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'UnitTest'))); + + $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'date_started' => strtotime('-1 day')))); + $this->assertEquals(2, $tc->create(array('title' => 'Task #1', 'project_id' => 1))); + $pds->updateTotals(1, date('Y-m-d', strtotime('-1 day'))); + + $this->assertTrue($ts->close(1)); + $pds->updateTotals(1, date('Y-m-d')); + + $metrics = $pds->getRawMetrics(1, date('Y-m-d', strtotime('-1days')), date('Y-m-d')); + $expected = array( + array( + 'day' => '2015-11-20', + 'avg_lead_time' => 0, + 'avg_cycle_time' => 43200, + ), + array( + 'day' => '2015-11-21', + 'avg_lead_time' => 0, + 'avg_cycle_time' => 43200, + ) + ); + + $this->assertEquals($expected, $metrics); + } +} -- cgit v1.2.3 From 2c80a90a6f878cf1d586196279afa92267f2d64a Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 22 Nov 2015 12:53:18 -0500 Subject: Add Bosnian translation (pull-request #1470) --- CONTRIBUTORS.md | 1 + ChangeLog | 1 + app/Locale/bs_BA/translations.php | 1071 +++++++++++++++++++++++++++ app/Locale/cs_CZ/translations.php | 3 + app/Locale/da_DK/translations.php | 3 + app/Locale/de_DE/translations.php | 3 + app/Locale/es_ES/translations.php | 3 + app/Locale/fi_FI/translations.php | 3 + app/Locale/fr_FR/translations.php | 3 + app/Locale/hu_HU/translations.php | 3 + app/Locale/id_ID/translations.php | 3 + app/Locale/it_IT/translations.php | 3 + app/Locale/ja_JP/translations.php | 3 + app/Locale/nb_NO/translations.php | 3 + app/Locale/nl_NL/translations.php | 3 + app/Locale/pl_PL/translations.php | 3 + app/Locale/pt_BR/translations.php | 3 + app/Locale/pt_PT/translations.php | 3 + app/Locale/ru_RU/translations.php | 3 + app/Locale/sr_Latn_RS/translations.php | 3 + app/Locale/sv_SE/translations.php | 3 + app/Locale/th_TH/translations.php | 3 + app/Locale/tr_TR/translations.php | 3 + app/Locale/zh_CN/translations.php | 3 + app/Model/Config.php | 2 + tests/units/Model/ProjectDailyStatsTest.php | 4 +- 26 files changed, 1140 insertions(+), 2 deletions(-) create mode 100644 app/Locale/bs_BA/translations.php (limited to 'tests/units') diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 30924370..7f3a8855 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -43,6 +43,7 @@ Contributors: - [Karol J](https://github.com/dzudek) - [Kiswa](https://github.com/kiswa) - [Kralo](https://github.com/kralo) +- [Kolesar](https://github.com/Kolesar) - [Lars Christian Schou](https://github.com/NegoZiatoR) - [Lesstat](https://github.com/Lesstat) - [Levlaz](https://github.com/levlaz) diff --git a/ChangeLog b/ChangeLog index acd09fdd..275793fe 100644 --- a/ChangeLog +++ b/ChangeLog @@ -10,6 +10,7 @@ Breaking changes: New features: * New automatic action: Assign a category based on a link +* Added Bosnian translation Improvements: diff --git a/app/Locale/bs_BA/translations.php b/app/Locale/bs_BA/translations.php new file mode 100644 index 00000000..9faa10ea --- /dev/null +++ b/app/Locale/bs_BA/translations.php @@ -0,0 +1,1071 @@ + ',', + 'number.thousands_separator' => '.', + 'None' => 'None', + 'edit' => 'uredi', + 'Edit' => 'Uredi', + 'remove' => 'ukloni', + 'Remove' => 'Ukloni', + 'Update' => 'Ažuriraj', + 'Yes' => 'Da', + 'No' => 'Ne', + 'cancel' => 'odustani', + 'or' => 'ili', + 'Yellow' => 'Žuta', + 'Blue' => 'Plava', + 'Green' => 'Zelena', + 'Purple' => 'Ljubičasta', + 'Red' => 'Crvena', + 'Orange' => 'Narandžasta', + 'Grey' => 'Siva', + 'Brown' => 'Smeđa', + 'Deep Orange' => 'Tamno narandžasta', + 'Dark Grey' => 'Tamno siva', + 'Pink' => 'Roze', + 'Teal' => 'Tirkizna', + 'Cyan' => 'Zelenkasto plava', + 'Lime' => 'Žućkasto zelena', + 'Light Green' => 'Svijetlo zelena', + 'Amber' => 'Ćilibarska', + 'Save' => 'Sačuvaj', + 'Login' => 'Prijava', + 'Official website:' => 'Zvanična stranica:', + 'Unassigned' => 'Nedodijeljen', + 'View this task' => 'Pregledaj zadatak', + 'Remove user' => 'Ukloni korisnika', + 'Do you really want to remove this user: "%s"?' => 'Da li zaista želiš da ukloniš korisnika: "%s"?', + 'New user' => 'Novi korisnik', + 'All users' => 'Svi korisnici', + 'Username' => 'Korisničko ime', + 'Password' => 'Šifra', + 'Administrator' => 'Administrator', + 'Sign in' => 'Prijava', + 'Users' => 'Korisnici', + 'No user' => 'Nema korisnika', + 'Forbidden' => 'Zabranjeno', + 'Access Forbidden' => 'Pristup zabranjen', + 'Edit user' => 'Uredi korisnika', + 'Logout' => 'Odjava', + 'Bad username or password' => 'Pogrešno korisničko ime ili šifra', + 'Edit project' => 'Uredi projekat', + 'Name' => 'Ime', + 'Projects' => 'Projekti', + 'No project' => 'Bez projekta', + 'Project' => 'Projekat', + 'Status' => 'Status', + 'Tasks' => 'Zadatak', + 'Board' => 'Tabla', + 'Actions' => 'Akcije', + 'Inactive' => 'Neaktivan', + 'Active' => 'Aktivan', + 'Add this column' => 'Dodaj kolonu', + '%d tasks on the board' => '%d zadataka na tabli', + '%d tasks in total' => '%d zadataka ukupno', + 'Unable to update this board.' => 'Nemogu da ažuriram ovu tablu.', + 'Edit board' => 'Izmijeni tablu', + 'Disable' => 'Onemogući', + 'Enable' => 'Omogući', + 'New project' => 'Novi projekat', + 'Do you really want to remove this project: "%s"?' => 'Da li želiš da ukloniš projekat: "%s"?', + 'Remove project' => 'Ukloni projekat', + 'Edit the board for "%s"' => 'Uredi tablu za "%s"', + 'All projects' => 'Svi projekti', + 'Change columns' => 'Zamijeni kolonu', + 'Add a new column' => 'Dodaj novu kolonu', + 'Title' => 'Naslov', + 'Nobody assigned' => 'Niko nije dodijeljen', + 'Assigned to %s' => 'Dodijeljen korisniku %s', + 'Remove a column' => 'Ukloni kolonu', + 'Remove a column from a board' => 'Ukloni kolonu sa table', + 'Unable to remove this column.' => 'Nemoguće uklanjanje kolone.', + 'Do you really want to remove this column: "%s"?' => 'Da li zaista želiš da ukoniš ovu kolonu: "%s"?', + 'This action will REMOVE ALL TASKS associated to this column!' => 'Ova akcija BRIŠE SVE ZADATKE vezane za ovu kolonu!', + 'Settings' => 'Podešavanja', + 'Application settings' => 'Podešavanja aplikacije', + 'Language' => 'Jezik', + 'Webhook token:' => 'Token:', + 'API token:' => 'Token za API', + 'Database size:' => 'Veličina baze:', + 'Download the database' => 'Preuzmi bazu', + 'Optimize the database' => 'Optimizuj bazu', + '(VACUUM command)' => '(Naredba VACUUM)', + '(Gzip compressed Sqlite file)' => '(Sqlite baza spakovana Gzip-om)', + 'Close a task' => 'Zatvori zadatak', + 'Edit a task' => 'Uredi zadatak', + 'Column' => 'Kolona', + 'Color' => 'Boja', + 'Assignee' => 'Izvršilac', + 'Create another task' => 'Dodaj zadatak', + 'New task' => 'Novi zadatak', + 'Open a task' => 'Otvori zadatak', + 'Do you really want to open this task: "%s"?' => 'Da li zaista želiš da otvoriš zadatak: "%s"?', + 'Back to the board' => 'Nazad na tablu', + 'Created on %B %e, %Y at %k:%M %p' => 'Kreiran %e %B %Y o %k:%M', + 'There is nobody assigned' => 'Niko nije dodijeljen!', + 'Column on the board:' => 'Kolona na tabli:', + 'Status is open' => 'Status otvoren', + 'Status is closed' => 'Status zatvoren', + 'Close this task' => 'Zatvori ovaj zadatak', + 'Open this task' => 'Otvori ovaj zadatak', + 'There is no description.' => 'Bez opisa.', + 'Add a new task' => 'Dodaj zadatak', + 'The username is required' => 'Korisničko ime je obavezno', + 'The maximum length is %d characters' => 'Maksimalna dužina je %d znakova', + 'The minimum length is %d characters' => 'Minimalna dužina je %d znakova', + 'The password is required' => 'Šifra je obavezna', + 'This value must be an integer' => 'Mora biti cio broj', + 'The username must be unique' => 'Korisničko ime mora biti jedinstveno', + 'The user id is required' => 'ID korisnika je obavezan', + 'Passwords don\'t match' => 'Šifre se ne podudaraju', + 'The confirmation is required' => 'Potvrda je obavezna', + 'The project is required' => 'Projekat je obavezan', + 'The id is required' => 'ID je obavezan', + 'The project id is required' => 'ID projekta je obavezan', + 'The project name is required' => 'Naziv projekta je obavezan', + 'The title is required' => 'Naslov je obavezan', + 'Settings saved successfully.' => 'Podešavanja uspješno sačuvana.', + 'Unable to save your settings.' => 'Nemoguće sačuvati podešavanja.', + 'Database optimization done.' => 'Optimizacija baze je završena.', + 'Your project have been created successfully.' => 'Projekat je uspješno napravljen.', + 'Unable to create your project.' => 'Nemoguće kreiranje projekta.', + 'Project updated successfully.' => 'Projekat je uspješno ažuriran.', + 'Unable to update this project.' => 'Nemoguće ažuriranje projekta.', + 'Unable to remove this project.' => 'Nemoguće uklanjanje projekta.', + 'Project removed successfully.' => 'Projekat uspješno uklonjen.', + 'Project activated successfully.' => 'Projekt uspješno aktiviran.', + 'Unable to activate this project.' => 'Nemoguće aktiviranje projekta.', + 'Project disabled successfully.' => 'Projekat uspješno deaktiviran.', + 'Unable to disable this project.' => 'nemoguće deaktiviranje projekta.', + 'Unable to open this task.' => 'Nemoguće otvaranje zadatka.', + 'Task opened successfully.' => 'Zadatak uspješno otvoren.', + 'Unable to close this task.' => 'Nije moguće zatvaranje ovog zadatka.', + 'Task closed successfully.' => 'Zadatak uspješno zatvoren.', + 'Unable to update your task.' => 'Nije moguće ažuriranje zadatka.', + 'Task updated successfully.' => 'Zadatak uspješno ažuriran.', + 'Unable to create your task.' => 'Nije moguće kreiranje zadatka.', + 'Task created successfully.' => 'Zadatak uspješno kreiran.', + 'User created successfully.' => 'Korisnik uspješno kreiran', + 'Unable to create your user.' => 'Nije uspjelo kreiranje korisnika.', + 'User updated successfully.' => 'Korisnik uspješno ažuriran.', + 'Unable to update your user.' => 'Nije moguće ažuriranje korisnika.', + 'User removed successfully.' => 'Korisnik uspješno uklonjen.', + 'Unable to remove this user.' => 'Nije moguće uklanjanje korisnika.', + 'Board updated successfully.' => 'Tabla uspješno ažurirana.', + 'Ready' => 'Spreman', + 'Backlog' => 'Zaliha', + 'Work in progress' => 'U izradi', + 'Done' => 'Gotovo', + 'Application version:' => 'Verzija aplikacije:', + 'Completed on %B %e, %Y at %k:%M %p' => 'Završeno u %e %B %Y o %k:%M', + '%B %e, %Y at %k:%M %p' => '%e %B %Y o %k:%M', + 'Date created' => 'Kreiran dana', + 'Date completed' => 'Završen dana', + 'Id' => 'Id', + '%d closed tasks' => '%d zatvorenih zadataka', + 'No task for this project' => 'Nema dodijeljenih zadataka ovom projektu', + 'Public link' => 'Javni link', + 'Change assignee' => 'Promjena izvršioca', + 'Change assignee for the task "%s"' => 'Promjena izvršioca za zadatak "%s"', + 'Timezone' => 'Vremenska zona', + 'Sorry, I didn\'t find this information in my database!' => 'Na žalost, nije pronađena informacija u bazi', + 'Page not found' => 'Strana nije pronađena', + 'Complexity' => 'Složenost', + 'Task limit' => 'Najviše zadataka', + 'Task count' => 'Broj zadataka', + 'Edit project access list' => 'Uredi prava pristupa projektu', + 'Allow this user' => 'Dozvoli ovog korisnika', + 'Don\'t forget that administrators have access to everything.' => 'Zapamti: Administrator može pristupiti svemu!', + 'Revoke' => 'Opozovi', + 'List of authorized users' => 'Spisak odobrenih korisnika', + 'User' => 'Korisnik', + 'Nobody have access to this project.' => 'Niko nema pristup ovom projektu', + 'Comments' => 'Komentari', + 'Write your text in Markdown' => 'Pisanje teksta pomoću Markdown', + 'Leave a comment' => 'Ostavi komentar', + 'Comment is required' => 'Komentar je obavezan', + 'Leave a description' => 'Dodaj opis', + 'Comment added successfully.' => 'Komentar uspješno dodan', + 'Unable to create your comment.' => 'Nemoguće kreiranje komentara', + 'Edit this task' => 'Uredi ovaj zadatak', + 'Due Date' => 'Treba biti gotovo do dana', + 'Invalid date' => 'Pogrešan datum', + 'Must be done before %B %e, %Y' => 'Mora biti gotovo prije %e %B %Y', + '%B %e, %Y' => '%e %B %Y', + '%b %e, %Y' => '%b %e, %Y', + 'Automatic actions' => 'Automatske akcije', + 'Your automatic action have been created successfully.' => 'Uspješno kreirana automatska akcija', + 'Unable to create your automatic action.' => 'Nemoguće kreiranje automatske akcije', + 'Remove an action' => 'Obriši akciju', + 'Unable to remove this action.' => 'Nije moguće obrisati akciju', + 'Action removed successfully.' => 'Akcija obrisana', + 'Automatic actions for the project "%s"' => 'Akcije za automatizaciju projekta "%s"', + 'Defined actions' => 'Definisane akcje', + 'Add an action' => 'dodaj akcju', + 'Event name' => 'Naziv događaja', + 'Action name' => 'Naziv akcije', + 'Action parameters' => 'Parametri akcije', + 'Action' => 'Akcija', + 'Event' => 'Događaj', + 'When the selected event occurs execute the corresponding action.' => 'Na izabrani događaj izvrši odgovarajuću akciju', + 'Next step' => 'Slijedeći korak', + 'Define action parameters' => 'Definiši parametre akcije', + 'Save this action' => 'Snimi akciju', + 'Do you really want to remove this action: "%s"?' => 'Da li da obrišem akciju "%s"?', + 'Remove an automatic action' => 'Obriši automatsku akciju', + 'Assign the task to a specific user' => 'Dodijeli zadatak određenom korisniku', + 'Assign the task to the person who does the action' => 'Dodeli zadatak korisniku koji je izvršio akciju', + 'Duplicate the task to another project' => 'Kopiraj akciju u drugi projekat', + 'Move a task to another column' => 'Premjesti zadatak u drugu kolonu', + 'Task modification' => 'Izmjene zadatka', + 'Task creation' => 'Kreiranje zadatka', + 'Closing a task' => 'Zatvaranja zadatka', + 'Assign a color to a specific user' => 'Dodeli boju korisniku', + 'Column title' => 'Naslov kolone', + 'Position' => 'Pozicija', + 'Move Up' => 'Podigni', + 'Move Down' => 'Spusti', + 'Duplicate to another project' => 'Dupliciraj u drugi projekat', + 'Duplicate' => 'Dupliciraj', + 'link' => 'link', + 'Comment updated successfully.' => 'Komentar uspješno ažuriran.', + 'Unable to update your comment.' => 'Neuspješno ažuriranje komentara.', + 'Remove a comment' => 'Obriši komentar', + 'Comment removed successfully.' => 'Komentar je uspješno obrisan.', + 'Unable to remove this comment.' => 'Neuspješno brisanje komentara.', + 'Do you really want to remove this comment?' => 'Da li zaista želiš obrisati ovaj komentar?', + 'Only administrators or the creator of the comment can access to this page.' => 'Samo administrator i kreator komentara mogu pristupiti ovoj stranici.', + 'Current password for the user "%s"' => 'Trenutna šifra korisnika "%s"', + 'The current password is required' => 'Trenutna šifra je obavezna', + 'Wrong password' => 'Pogrešna šifra', + 'Unknown' => 'Nepoznat', + 'Last logins' => 'Posljednje prijave', + 'Login date' => 'Datum prijave', + 'Authentication method' => 'Metod autentikacije', + 'IP address' => 'IP adresa', + 'User agent' => 'Browser', + 'Persistent connections' => 'Stalna konekcija', + 'No session.' => 'Bez sesije', + 'Expiration date' => 'Ističe', + 'Remember Me' => 'Zapamti me', + 'Creation date' => 'Datum kreiranja', + 'Everybody' => 'Svi', + 'Open' => 'Otvoreni', + 'Closed' => 'Zatvoreni', + 'Search' => 'Pretraga', + 'Nothing found.' => 'Ništa nije pronađeno', + 'Due date' => 'Treba biti gotovo do dana', + 'Others formats accepted: %s and %s' => 'Ostali podržani formati: %s i %s', + 'Description' => 'Opis', + '%d comments' => '%d Komentara', + '%d comment' => '%d Komentar', + 'Email address invalid' => 'Pogrešan e-mail', + 'Your external account is not linked anymore to your profile.' => 'Vaš vanjski korisnički profil nije više povezan.', + 'Unable to unlink your external account.' => 'Nemoguće ukloniti vezu s vanjskim korisničkim profilom', + 'External authentication failed' => 'Vanjska autentikacija nije uspostavljena', + 'Your external account is linked to your profile successfully.' => 'Uspješno uspostavljena vanjska autentikacija', + 'Email' => 'E-mail', + 'Link my Google Account' => 'Poveži sa Google nalogom', + 'Unlink my Google Account' => 'Ukini vezu sa Google nalogom', + 'Login with my Google Account' => 'Prijavi se preko Google naloga', + 'Project not found.' => 'Projekat nije pronađen.', + 'Task removed successfully.' => 'Zadatak uspješno uklonjen.', + 'Unable to remove this task.' => 'Nemoguće uklanjanje zadatka.', + 'Remove a task' => 'Ukloni zadatak', + 'Do you really want to remove this task: "%s"?' => 'Da li zaista želiš ukloniti zadatak "%s"?', + 'Assign automatically a color based on a category' => 'Automatski dodijeli boju po kategoriji', + 'Assign automatically a category based on a color' => 'Automatski dodijeli kategoriju po boji', + 'Task creation or modification' => 'Kreiranje ili izmjena zadatka', + 'Category' => 'Kategorija', + 'Category:' => 'Kategorija:', + 'Categories' => 'Kategorije', + 'Category not found.' => 'Kategorija nije pronađena', + 'Your category have been created successfully.' => 'Uspješno kreirana kategorija.', + 'Unable to create your category.' => 'Nije moguće kreirati kategoriju.', + 'Your category have been updated successfully.' => 'Kategorija je uspješno ažurirana', + 'Unable to update your category.' => 'Nemoguće izmijeniti kategoriju', + 'Remove a category' => 'Ukloni kategoriju', + 'Category removed successfully.' => 'Kategorija uspešno uklonjena.', + 'Unable to remove this category.' => 'Nije moguće ukloniti kategoriju.', + 'Category modification for the project "%s"' => 'Izmjena kategorije za projekat "%s"', + 'Category Name' => 'Naziv kategorije', + 'Add a new category' => 'Dodaj novu kategoriju', + 'Do you really want to remove this category: "%s"?' => 'Da li zaista želiš ukloniti kategoriju: "%s"?', + 'All categories' => 'Sve kategorije', + 'No category' => 'Bez kategorije', + 'The name is required' => 'Naziv je obavezan', + 'Remove a file' => 'Ukloni fajl', + 'Unable to remove this file.' => 'Fajl nije moguće ukloniti.', + 'File removed successfully.' => 'Uspješno uklonjen fajl.', + 'Attach a document' => 'Prikači dokument', + 'Do you really want to remove this file: "%s"?' => 'Da li da uklonim fajl: "%s"?', + 'Attachments' => 'Prilozi', + 'Edit the task' => 'Uredi zadatak', + 'Edit the description' => 'Uredi opis zadatka', + 'Add a comment' => 'Dodaj komentar', + 'Edit a comment' => 'Izmijeni komentar', + 'Summary' => 'Pregled', + 'Time tracking' => 'Praćenje vremena', + 'Estimate:' => 'Procjena:', + 'Spent:' => 'Potrošeno:', + 'Do you really want to remove this sub-task?' => 'Da li da zaista želiš ukloniti pod-zdadatak?', + 'Remaining:' => 'Preostalo:', + 'hours' => 'sati', + 'spent' => 'potrošeno', + 'estimated' => 'procijenjeno', + 'Sub-Tasks' => 'Pod-zadaci', + 'Add a sub-task' => 'Dodaj pod-zadatak', + 'Original estimate' => 'Originalna procjena', + 'Create another sub-task' => 'Dodaj novi pod-zadatak', + 'Time spent' => 'Utrošeno vrijeme', + 'Edit a sub-task' => 'Izmijeni pod-zadatak', + 'Remove a sub-task' => 'Ukloni pod-zadatak', + 'The time must be a numeric value' => 'Vrijeme mora biti broj', + 'Todo' => 'Za uraditi', + 'In progress' => 'U radu', + 'Sub-task removed successfully.' => 'Pod-zadatak uspješno uklonjen.', + 'Unable to remove this sub-task.' => 'Nemoguće ukloniti pod-zadatak.', + 'Sub-task updated successfully.' => 'Pod-zadatak uspješno ažuriran.', + 'Unable to update your sub-task.' => 'Nemoguće ažurirati pod-zadatak.', + 'Unable to create your sub-task.' => 'Nemoguće dodati pod-zadatak.', + 'Sub-task added successfully.' => 'Pod-zadatak uspješno dodan.', + 'Maximum size: ' => 'Maksimalna veličina: ', + 'Unable to upload the file.' => 'Nije moguće snimiti fajl.', + 'Display another project' => 'Prikaži drugi projekat', + 'Login with my Github Account' => 'Prijavi me s mojim Github korisničkim računom', + 'Link my Github Account' => 'Poveži s mojim Github korisničkim računom', + 'Unlink my Github Account' => 'Odbavi vez s mojim Github korisničkim računom', + 'Created by %s' => 'Kreirao %s', + 'Last modified on %B %e, %Y at %k:%M %p' => 'Posljednja izmjena %e %B %Y o %k:%M', + 'Tasks Export' => 'Izvoz zadataka', + 'Tasks exportation for "%s"' => 'Izvoz zadataka za "%s"', + 'Start Date' => 'Početni datum', + 'End Date' => 'Datum završetka', + 'Execute' => 'Izvrši', + 'Task Id' => 'Identifikator zadatka', + 'Creator' => 'Autor', + 'Modification date' => 'Datum izmjene', + 'Completion date' => 'Datum završetka', + 'Clone' => 'Kloniraj', + 'Project cloned successfully.' => 'Projekat uspješno kloniran.', + 'Unable to clone this project.' => 'Nije moguće klonirati projekat.', + 'Enable email notifications' => 'Omogući obavještenja e-mailom', + 'Task position:' => 'Pozicija zadatka:', + 'The task #%d have been opened.' => 'Zadatak #%d je otvoren.', + 'The task #%d have been closed.' => 'Zadatak #%d je zatvoren.', + 'Sub-task updated' => 'Pod-zadatak izmijenjen', + 'Title:' => 'Naslov:', + 'Status:' => 'Status:', + 'Assignee:' => 'Izvršilac:', + 'Time tracking:' => 'Praćenje vremena:', + 'New sub-task' => 'Novi pod-zadatak', + 'New attachment added "%s"' => 'Ubačen novi prilog "%s"', + 'Comment updated' => 'Komentar ažuriran', + 'New comment posted by %s' => '%s ostavio novi komentar', + 'New attachment' => 'Novi prilog', + 'New comment' => 'Novi komentar', + 'New subtask' => 'Novi pod-zadatak', + 'Subtask updated' => 'Pod-zadatak ažuriran', + 'Task updated' => 'Zadatak ažuriran', + 'Task closed' => 'Zadatak je zatvoren', + 'Task opened' => 'Zadatak je otvoren', + 'I want to receive notifications only for those projects:' => 'Želim obavještenja samo za ove projekte:', + 'view the task on Kanboard' => 'Pregledaj zadatke', + 'Public access' => 'Javni pristup', + 'User management' => 'Upravljanje korisnicima', + 'Active tasks' => 'Aktivni zadaci', + 'Disable public access' => 'Zabrani javni pristup', + 'Enable public access' => 'Dozvoli javni pristup', + 'Public access disabled' => 'Javni pristup onemogućen!', + 'Do you really want to disable this project: "%s"?' => 'Da li zaista želiš da deaktiviraš projekat: "%s"?', + 'Do you really want to enable this project: "%s"?' => 'Da li zaista želiš da aktiviraš projekat: "%s"?', + 'Project activation' => 'Aktivacija projekta', + 'Move the task to another project' => 'Premjesti zadatak u drugi projekat', + 'Move to another project' => 'Premjesti u drugi projekat', + 'Do you really want to duplicate this task?' => 'Da li zaista želiš duplicirati ovaj zadatak?', + 'Duplicate a task' => 'Dupliciraj zadatak', + 'External accounts' => 'Vanjski korisnički računi', + 'Account type' => 'Tip korisničkog računa', + 'Local' => 'Lokalno', + 'Remote' => 'Udaljeno', + 'Enabled' => 'Omogućeno', + 'Disabled' => 'Onemogućeno', + 'Username:' => 'Korisničko ime:', + 'Name:' => 'Ime i Prezime', + 'Email:' => 'Email: ', + 'Notifications:' => 'Obavještenja: ', + 'Notifications' => 'Obavještenja', + 'Group:' => 'Grupa:', + 'Regular user' => 'Standardni korisnik', + 'Account type:' => 'Vrsta korisničkog računa:', + 'Edit profile' => 'Uredi profil', + 'Change password' => 'Promijeni šifru', + 'Password modification' => 'Izmjena šifre', + 'External authentications' => 'Vanjske autentikacije', + 'Google Account' => 'Google korisnički račun', + 'Github Account' => 'Github korisnički račun', + 'Never connected.' => 'Bez konekcija.', + 'No account linked.' => 'Bez povezanih korisničkih računa.', + 'Account linked.' => 'Korisnički račun povezan.', + 'No external authentication enabled.' => 'Bez omogućenih vanjskih autentikacija.', + 'Password modified successfully.' => 'Uspješna izmjena šifre.', + 'Unable to change the password.' => 'Nije moguće izmijeniti šifru.', + 'Change category for the task "%s"' => 'Izijmeni kategoriju zadatka "%s"', + 'Change category' => 'Izmijeni kategoriju', + '%s updated the task %s' => '%s izmijenio zadatak %s', + '%s opened the task %s' => '%s otvorio zadatak %s', + '%s moved the task %s to the position #%d in the column "%s"' => '%s premjestio zadatak %s na poziciju #%d u koloni "%s"', + '%s moved the task %s to the column "%s"' => '%s premjestio zadatak %s u kolonu "%s"', + '%s created the task %s' => '%s kreirao zadatak %s', + '%s closed the task %s' => '%s zatvorio zadatak %s', + '%s created a subtask for the task %s' => '%s kreirao pod-zadatak zadatka %s', + '%s updated a subtask for the task %s' => '%s izmijenio pod-zadatak zadatka %s', + 'Assigned to %s with an estimate of %s/%sh' => 'Dodijeljen korisniku %s uz procjenu vremena %s/%sh', + 'Not assigned, estimate of %sh' => 'Ne dodijeljen, procijenjeno vrijeme %sh', + '%s updated a comment on the task %s' => '%s izmijenio komentar zadatka %s', + '%s commented the task %s' => '%s komentarisao zadatak %s', + '%s\'s activity' => 'Aktivnosti %s', + 'RSS feed' => 'RSS kanal', + '%s updated a comment on the task #%d' => '%s izmijenio komentar zadatka #%d', + '%s commented on the task #%d' => '%s komentarisao zadatak #%d', + '%s updated a subtask for the task #%d' => '%s izmijenio pod-zadatak zadatka #%d', + '%s created a subtask for the task #%d' => '%s kreirao pod-zadatak zadatka #%d', + '%s updated the task #%d' => '%s ažurirao zadatak #%d', + '%s created the task #%d' => '%s kreirao zadatak #%d', + '%s closed the task #%d' => '%s zatvorio zadatak #%d', + '%s open the task #%d' => '%s otvorio zadatak #%d', + '%s moved the task #%d to the column "%s"' => '%s premjestio zadatak #%d u kolonu "%s"', + '%s moved the task #%d to the position %d in the column "%s"' => '%s premjestio zadatak #%d na poziciju %d u koloni "%s"', + 'Activity' => 'Aktivnosti', + 'Default values are "%s"' => 'Podrazumijevane vrijednosti su: "%s"', + 'Default columns for new projects (Comma-separated)' => 'Podrazumijevane kolone za novi projekat (Odvojeni zarezom)', + 'Task assignee change' => 'Promijena izvršioca zadatka', + '%s change the assignee of the task #%d to %s' => '%s zamijeni izvršioca za zadatak #%d u %s', + '%s changed the assignee of the task %s to %s' => '%s promijenio izvršioca za zadatak %s u %s', + 'New password for the user "%s"' => 'Nova šifra korisnika "%s"', + 'Choose an event' => 'Izaberi događaj', + 'Github commit received' => 'Github: commit dobijen', + 'Github issue opened' => 'Github: otvoren problem', + 'Github issue closed' => 'Github: zatvoren problem', + 'Github issue reopened' => 'Github: ponovo otvoren problem', + 'Github issue assignee change' => 'Github: izmijenjen izvršioc problema', + 'Github issue label change' => 'Github: izmjena etikete problema', + 'Create a task from an external provider' => 'Kreiraj zadatak preko posrednika', + 'Change the assignee based on an external username' => 'Izmijene izvršioca bazirano na vanjskom korisničkom imenu', + 'Change the category based on an external label' => 'Izmijene kategorije bazirano na vanjskoj etiketi', + 'Reference' => 'Referenca', + 'Reference: %s' => 'Referenca: %s', + 'Label' => 'Etiketa', + 'Database' => 'Baza', + 'About' => 'O', + 'Database driver:' => 'Database driver:', + 'Board settings' => 'Postavke table', + 'URL and token' => 'URL i token', + 'Webhook settings' => 'Postavke za webhook', + 'URL for task creation:' => 'URL za kreiranje zadataka', + 'Reset token' => 'Resetuj token', + 'API endpoint:' => 'API endpoint', + 'Refresh interval for private board' => 'Interval osvježavanja privatnih tabli', + 'Refresh interval for public board' => 'Interval osvježavanja javnih tabli', + 'Task highlight period' => 'Period naznačavanja zadatka', + 'Period (in second) to consider a task was modified recently (0 to disable, 2 days by default)' => 'Period (u sekundama) u kom su se događale promjene na zadatku (0 je onemogućeno, 2 dana je uobičajeno)', + 'Frequency in second (60 seconds by default)' => 'Frekvencija u sekundama (60 sekundi je uobičajeno)', + 'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frekvencija u sekundama (0 je onemogućeno u budućnosti, 10 sekundi je uobičajeno)', + 'Application URL' => 'URL aplikacje', + 'Example: http://example.kanboard.net/ (used by email notifications)' => 'Primjer: http://example.kanboard.net/ (koristi se u obavještenjima putem email-a)', + 'Token regenerated.' => 'Token regenerisan.', + 'Date format' => 'Format datuma', + 'ISO format is always accepted, example: "%s" and "%s"' => 'Format ISO je uvek prihvatljiv, primjer: "%s", "%s"', + 'New private project' => 'Novi privatni projekat', + 'This project is private' => 'Ovaj projekat je privatan', + 'Type here to create a new sub-task' => 'Piši ovdje za kreiranje novog pod-zadatka', + 'Add' => 'Dodaj', + 'Estimated time: %s hours' => 'Procijenjeno vrijeme: %s sati', + 'Time spent: %s hours' => 'Utrošeno vrijeme: %s sati', + 'Started on %B %e, %Y' => 'Započeto dana %e %B %Y', + 'Start date' => 'Datum početka', + 'Time estimated' => 'Procijenjeno vrijeme', + 'There is nothing assigned to you.' => 'Ništa vam nije dodijeljeno', + 'My tasks' => 'Moji zadaci', + 'Activity stream' => 'Spisak aktivnosti', + 'Dashboard' => 'Panel', + 'Confirmation' => 'Potvrda', + 'Allow everybody to access to this project' => 'Dozvoli svima pristup ovom projektu', + 'Everybody have access to this project.' => 'Svima je dozvoljen pristup ovom projektu.', + 'Webhooks' => 'Webhooks', + 'API' => 'API', + 'Github webhooks' => 'Github webhooks', + 'Help on Github webhooks' => 'Pomoć na Github webhooks', + 'Create a comment from an external provider' => 'Napravi komentar preko vanjskog posrednika', + 'Github issue comment created' => 'Github: dodan komentar za problem', + 'Project management' => 'Upravljanje projektima', + 'My projects' => 'Moji projekti', + 'Columns' => 'Kolone', + 'Task' => 'Zadatak', + 'Your are not member of any project.' => 'Nisi član ni jednog projekta', + 'Percentage' => 'Procenat', + 'Number of tasks' => 'Broj zadataka', + 'Task distribution' => 'Podjela zadataka', + 'Reportings' => 'Izveštaji', + 'Task repartition for "%s"' => 'Zaduženja zadataka za "%s"', + 'Analytics' => 'Analiza', + 'Subtask' => 'Pod-zadatak', + 'My subtasks' => 'Moji pod-zadaci', + 'User repartition' => 'Zaduženja korisnika', + 'User repartition for "%s"' => 'Zaduženja korisnika za "%s"', + 'Clone this project' => 'Kloniraj ovaj projekat', + 'Column removed successfully.' => 'Kolona uspješno uklonjena.', + 'Github Issue' => 'Github problemi', + 'Not enough data to show the graph.' => 'Nedovoljno podataka za prikaz na grafikonu.', + 'Previous' => 'Prethodni', + 'The id must be an integer' => 'ID mora biti cjeloviti broj', + 'The project id must be an integer' => 'ID projekta mora biti cjeloviti broj', + 'The status must be an integer' => 'Status mora biti cjeloviti broj', + 'The subtask id is required' => 'ID pod-zadataka je obavezan', + 'The subtask id must be an integer' => 'ID pod-zadatka mora biti cjeloviti broj', + 'The task id is required' => 'ID zadatka je obavezan', + 'The task id must be an integer' => 'ID zadatka mora biti cjeloviti broj', + 'The user id must be an integer' => 'ID korisnika mora biti cjeloviti broj', + 'This value is required' => 'Vrijednost je obavezna', + 'This value must be numeric' => 'Vrijednost mora biti broj', + 'Unable to create this task.' => 'Nije moguće kreirati zadatak.', + 'Cumulative flow diagram' => 'Zbirni dijagram toka', + 'Cumulative flow diagram for "%s"' => 'Zbirni dijagram toka za "%s"', + 'Daily project summary' => 'Zbirni pregled po danima', + 'Daily project summary export' => 'Izvoz zbirnog pregleda po danima', + 'Daily project summary export for "%s"' => 'Izvoz zbirnog pregleda po danima za "%s"', + 'Exports' => 'Izvozi', + 'This export contains the number of tasks per column grouped per day.' => 'Ovaj izvoz sadržava broj zadataka po koloni grupisanih po danima.', + 'Nothing to preview...' => 'Ništa za pokazati...', + 'Preview' => 'Pregled', + 'Write' => 'Piši', + 'Active swimlanes' => 'Aktivne swimline trake', + 'Add a new swimlane' => 'Dodaj novu swimline traku', + 'Change default swimlane' => 'Preimenuj podrazumijevanu swimline traku', + 'Default swimlane' => 'Podrazumijevana swimline traka', + 'Do you really want to remove this swimlane: "%s"?' => 'Da li zaista želiš ukloniti ovu swimline traku: "%s"?', + 'Inactive swimlanes' => 'Neaktivne swimline trake', + 'Set project manager' => 'Postavi kao projekt menadžera', + 'Set project member' => 'Postavi kao člana projekta ', + 'Remove a swimlane' => 'Ukloni swimline traku', + 'Rename' => 'Preimenuj', + 'Show default swimlane' => 'Prikaži podrazumijevanu swimline traku', + 'Swimlane modification for the project "%s"' => 'Izmjene swimline trake za projekat "%s"', + 'Swimlane not found.' => 'Swimline traka nije pronađena.', + 'Swimlane removed successfully.' => 'Swimline traka uspješno uklonjena.', + 'Swimlanes' => 'Swimline trake', + 'Swimlane updated successfully.' => 'Swimline traka uspjeno ažurirana.', + 'The default swimlane have been updated successfully.' => 'Podrazumijevana swimline traka uspješno ažurirana.', + 'Unable to create your swimlane.' => 'Nemoguće kreirati swimline traku.', + 'Unable to remove this swimlane.' => 'Nemoguće ukloniti swimline traku.', + 'Unable to update this swimlane.' => 'Nemoguće ažurirati swimline traku.', + 'Your swimlane have been created successfully.' => 'Swimline traka je uspješno kreirana.', + 'Example: "Bug, Feature Request, Improvement"' => 'Npr: "Greška, Zahtjev za izmjenama, Poboljšanje"', + 'Default categories for new projects (Comma-separated)' => 'Podrazumijevane kategorije za novi projekat', + 'Gitlab commit received' => 'Gitlab: commit dobijen', + 'Gitlab issue opened' => 'Gitlab: problem otvoren', + 'Gitlab issue closed' => 'Gitlab: problem zatvoren', + 'Gitlab webhooks' => 'Gitlab webhooks', + 'Help on Gitlab webhooks' => 'Pomoc na Gitlab webhooks', + 'Integrations' => 'Integracije', + 'Integration with third-party services' => 'Integracija sa uslugama vanjskih servisa', + 'Role for this project' => 'Uloga u ovom projektu', + 'Project manager' => 'Manadžer projekta', + 'Project member' => 'Učesnik projekta', + 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Projekt menadžer može mijenjati postavke projekta i ima više prava od standardnog korisnika.', + 'Gitlab Issue' => 'Gitlab problemi', + 'Subtask Id' => 'ID pod-zadatka', + 'Subtasks' => 'Pod-zadaci', + 'Subtasks Export' => 'Izvoz pod-zadataka', + 'Subtasks exportation for "%s"' => 'Izvoz pod-zadataka za "%s"', + 'Task Title' => 'Naslov zadatka', + 'Untitled' => 'Bez naslova', + 'Application default' => 'Podrazumijevano od aplikacije', + 'Language:' => 'Jezik:', + 'Timezone:' => 'Vremenska zona:', + 'All columns' => 'Sve kolone', + 'Calendar' => 'Kalendar', + 'Next' => 'Slijedeći', + '#%d' => '#%d', + 'All swimlanes' => 'Sve swimline trake', + 'All colors' => 'Sve boje', + 'Moved to column %s' => 'Premješten u kolonu %s', + 'Change description' => 'Promijeni opis', + 'User dashboard' => 'Korisnički panel', + 'Allow only one subtask in progress at the same time for a user' => 'Dozvoli samo jedan pod-zadatak "u radu" po korisniku', + 'Edit column "%s"' => 'Uredi kolonu "%s"', + 'Select the new status of the subtask: "%s"' => 'Izaberi novi status za pod-zadatak: "%s"', + 'Subtask timesheet' => 'Vremenska tabela za pod-zadatak', + 'There is nothing to show.' => 'Nema ništa za pokazati', + 'Time Tracking' => 'Praćenje vremena', + 'You already have one subtask in progress' => 'Već imaš jedan pod-zadatak "u radu"', + 'Which parts of the project do you want to duplicate?' => 'Koje delove projekta želiš duplicirati?', + 'Disallow login form' => 'Zabrani prijavnu formu', + 'Bitbucket commit received' => 'Bitbucket: commit dobijen', + 'Bitbucket webhooks' => 'Bitbucket: webhooks', + 'Help on Bitbucket webhooks' => 'Pomoć na Bitbucket webhooks', + 'Start' => 'Početak', + 'End' => 'Kraj', + 'Task age in days' => 'Trajanje zadatka u danima', + 'Days in this column' => 'Dani u ovoj koloni', + '%dd' => '%dd', + 'Add a link' => 'Dodaj vezu', + 'Add a new link' => 'Dodaj novu vezu', + 'Do you really want to remove this link: "%s"?' => 'Da li zaista želite ukloniti ovu vezu: "%s"?', + 'Do you really want to remove this link with task #%d?' => 'Da li zaista želite ukloniti ovu vezu sa zadatkom #%d?', + 'Field required' => 'Polje je obavezno', + 'Link added successfully.' => 'Veza je uspješno dodana.', + 'Link updated successfully.' => 'Veza je uspješno ažurirana.', + 'Link removed successfully.' => 'Veza je uspješno uklonjena.', + 'Link labels' => 'Veza s etiketama', + 'Link modification' => 'Veza modifikacija', + 'Links' => 'Veze', + 'Link settings' => 'Postavke veza', + 'Opposite label' => 'Suprotna etiketa', + 'Remove a link' => 'Ukloni vezu', + 'Task\'s links' => 'Veze zadatka', + 'The labels must be different' => 'Etikete moraju biti različite', + 'There is no link.' => 'Ovdje nema veza', + 'This label must be unique' => 'Ova etiketa mora biti jedinstvena', + 'Unable to create your link.' => 'Nemoguće napraviti vezu.', + 'Unable to update your link.' => 'Nemoguće ažurirati vezu.', + 'Unable to remove this link.' => 'Nemoguće ukloniti vezu.', + 'relates to' => 'relacija sa', + 'blocks' => 'blokira', + 'is blocked by' => 'je blokiran od', + 'duplicates' => 'duplicira', + 'is duplicated by' => 'je dupliciran od', + 'is a child of' => 'je dijete od', + 'is a parent of' => 'je roditelj od', + 'targets milestone' => 'cilj prekretnice', + 'is a milestone of' => 'je od prekretnice', + 'fixes' => 'popravlja', + 'is fixed by' => 'je popravljen od', + 'This task' => 'Ovaj zadatak', + '<1h' => '<1h', + '%dh' => '%dh', + '%b %e' => '%b %e', + 'Expand tasks' => 'Proširi zadatke', + 'Collapse tasks' => 'Skupi zadatke', + 'Expand/collapse tasks' => 'Proširi/skupi zadatke', + 'Close dialog box' => 'Skupi dialog', + 'Submit a form' => 'Pošalji obrazac', + 'Board view' => 'Pregled ploče', + 'Keyboard shortcuts' => 'Prečice tastature', + 'Open board switcher' => 'Otvori prekidače ploče', + 'Application' => 'Aplikacija', + 'since %B %e, %Y at %k:%M %p' => 'od %B %e, %Y do %k:%M %p', + 'Compact view' => 'Kompaktan pregled', + 'Horizontal scrolling' => 'Horizontalno listanje', + 'Compact/wide view' => 'Skupi/raširi pregled', + 'No results match:' => 'Nema rezultata:', + 'Currency' => 'Valuta', + 'Files' => 'Fajlovi', + 'Images' => 'Slike', + 'Private project' => 'Privatni projekat', + 'AUD - Australian Dollar' => 'AUD - Australijski dolar', + 'CAD - Canadian Dollar' => 'CAD - Kanadski dolar', + 'CHF - Swiss Francs' => 'CHF - Švicarski franak', + 'Custom Stylesheet' => 'Prilagođeni stil', + 'download' => 'preuzmi', + 'EUR - Euro' => 'EUR - Evro', + 'GBP - British Pound' => 'GBP - Britanska funta', + 'INR - Indian Rupee' => 'INR - Indijski rupi', + 'JPY - Japanese Yen' => 'JPY - Japanski jen', + 'NZD - New Zealand Dollar' => 'NZD - Novozelandski dolar', + 'RSD - Serbian dinar' => 'RSD - Srpski dinar', + 'USD - US Dollar' => 'USD - Američki dolar', + 'Destination column' => 'Odredišna kolona', + 'Move the task to another column when assigned to a user' => 'Premjesti zadatak u neku drugu kolonu kada se dodijeli izvršiocu', + 'Move the task to another column when assignee is cleared' => 'Premjesti zadatak u neku drugu kolonu kada se ukloni izvršilac', + 'Source column' => 'Izvorna kolona', + 'Transitions' => 'Prelaz', + 'Executer' => 'Izvršilac', + 'Time spent in the column' => 'Vrijeme provedeno u koloni', + 'Task transitions' => 'Prelazi zadatka', + 'Task transitions export' => 'Izvezi prelaze zadatka', + 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => 'Ovaj izvještaj sadržava sve kolone premještanja za svaki zadatak s datumom, te korisnikom i utrošenim vremenom za svaki premještaj.', + 'Currency rates' => 'Stopa valute', + 'Rate' => 'Stopa', + 'Change reference currency' => 'Promijeni referencu valute', + 'Add a new currency rate' => 'Dodaj novu stopu valute', + 'Reference currency' => 'Referenca valute', + 'The currency rate have been added successfully.' => 'Stopa valute je uspješno dodana.', + 'Unable to add this currency rate.' => 'Nemoguće dodati stopu valute.', + 'Webhook URL' => 'Webhook URL', + '%s remove the assignee of the task %s' => '%s je uklonio izvršioca zadatka %s', + 'Enable Gravatar images' => 'Omogući Gravatar slike', + 'Information' => 'Informacije', + 'Check two factor authentication code' => 'Provjera faktor-dva autentifikacionog koda', + 'The two factor authentication code is not valid.' => 'Faktor-dva autentifikacionog koda nije validan.', + 'The two factor authentication code is valid.' => 'Faktor-dva autentifikacionog koda je validan.', + 'Code' => 'Kod', + 'Two factor authentication' => 'Faktor-dva autentifikacija', + 'Enable/disable two factor authentication' => 'Omogući/onemogući faktor-dva autentifikaciju', + 'This QR code contains the key URI: ' => 'Ovaj QR kod sadržava ključni URL: ', + 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Sačuvaj tajni klju u svom TOTP softveru (npr. Google Authenticator or FreeOTP)', + 'Check my code' => 'Provjeri moj kod', + 'Secret key: ' => 'Tajni ključ: ', + 'Test your device' => 'Testiraj svoj uređaj', + 'Assign a color when the task is moved to a specific column' => 'Dodijeli boju kada je zadatak pomjeren u odabranu kolonu', + '%s via Kanboard' => '%s uz pomoć Kanboard-a', + 'uploaded by: %s' => 'dodano od strane: %s', + 'uploaded on: %s' => 'dodano na: %s', + 'size: %s' => 'veličina: %s', + 'Burndown chart for "%s"' => 'Grafikon izgaranja za "%s"', + 'Burndown chart' => 'Grafikon izgaranja', + 'This chart show the task complexity over the time (Work Remaining).' => 'Ovaj grafikon pokazuje kompleksnost zadatka u vremenu (Preostalo vremena)', + 'Screenshot taken %s' => 'Slika ekrana uzeta %s', + 'Add a screenshot' => 'Dodaj sliku ekrana', + 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Uzmi sliku ekrana i pritisni CTRL+V ili ⌘+V da zalijepiš ovdje.', + 'Screenshot uploaded successfully.' => 'Slika ekrana uspješno dodana.', + 'SEK - Swedish Krona' => 'SEK - Švedska kruna', + 'The project identifier is an optional alphanumeric code used to identify your project.' => 'Identifikator projekta je opcionalni alfanumerički kod koji se koristi za identifikaciju projekta.', + 'Identifier' => 'Identifikator', + 'Disable two factor authentication' => 'Onemogući faktor-dva autentifikaciju', + 'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Da li zaista želiš onemogućiti faktor-dva autentifikaciju: %s?', + 'Edit link' => 'Uredi vezu', + 'Start to type task title...' => 'Počni pisati naslov zadatka...', + 'A task cannot be linked to itself' => 'Zadatak ne može biti povezan sa samim sobom', + 'The exact same link already exists' => 'Ista veza već postoji', + 'Recurrent task is scheduled to be generated' => 'Ponavljajući zadatak je pripremljen da bude kreiran', + 'Recurring information' => 'Informacije o ponavljanju', + 'Score' => 'Uspjeh', + 'The identifier must be unique' => 'Identifikator mora biti jedinstven', + 'This linked task id doesn\'t exists' => 'Povezani ID zadatka ne postoji', + 'This value must be alphanumeric' => 'Ova vrijednost mora biti alfanumerička', + 'Edit recurrence' => 'Uredi ponavljanje', + 'Generate recurrent task' => 'Napravi ponavljajući zadatak', + 'Trigger to generate recurrent task' => 'Okidač koji pravi ponavljajući zadatak', + 'Factor to calculate new due date' => 'Faktor za računanje novog datuma završetka', + 'Timeframe to calculate new due date' => 'Vremenski okvir za računanje novog datuma završetka', + 'Base date to calculate new due date' => 'Početni datum za računanje novog datuma završetka', + 'Action date' => 'Datum akcije', + 'Base date to calculate new due date: ' => 'Početni datum za računanje novog datuma završetka: ', + 'This task has created this child task: ' => 'Ovaj zadatak će napraviti zadatak-dijete: ', + 'Day(s)' => 'Dan(i)', + 'Existing due date' => 'Postojeći datum završetka', + 'Factor to calculate new due date: ' => 'Faktor za računanje novog datuma završetka: ', + 'Month(s)' => 'Mjesec(i)', + 'Recurrence' => 'Referenca', + 'This task has been created by: ' => 'Ovaj zadatak će napravio: ', + 'Recurrent task has been generated:' => 'Ponavljajući zadatak je napravio:', + 'Timeframe to calculate new due date: ' => 'Vremenski okvir za računanje novog datuma završetka:', + 'Trigger to generate recurrent task: ' => 'Okidač za pravljenje ponavljajućeg zadatka', + 'When task is closed' => 'Kada je zadatak zatvoren', + 'When task is moved from first column' => 'Kada je zadatak premješten iz prve kolone', + 'When task is moved to last column' => 'Kada je zadatak premješten u posljednju kolonu', + 'Year(s)' => 'Godina/e', + 'Calendar settings' => 'Postavke kalendara', + 'Project calendar view' => 'Pregled kalendara projekta', + 'Project settings' => 'Postavke projekta', + 'Show subtasks based on the time tracking' => 'Prikaži pod-zadatke bazirano na vremenskom praćenju', + 'Show tasks based on the creation date' => 'Prikaži zadatke bazirano na vremenu otvaranja', + 'Show tasks based on the start date' => 'Prikaži zadatke bazirano na vremenu početka rada', + 'Subtasks time tracking' => 'Vremensko praćenje pod-zadataka', + 'User calendar view' => 'Pregled korisničkog kalendara', + 'Automatically update the start date' => 'Automatski ažuriraj početni datum', + 'iCal feed' => 'iCal kanal', + 'Preferences' => 'Postavke', + 'Security' => 'Sigurnost', + 'Two factor authentication disabled' => 'Faktor-dva autentifikacija onemogućena', + 'Two factor authentication enabled' => 'Faktor-dva autentifikacija omogućena', + 'Unable to update this user.' => 'Nemoguće ažurirati ovog korisnika', + 'There is no user management for private projects.' => 'Nema mehanizma za upravljanje korisnicima kod privatnih projekata.', + 'User that will receive the email' => 'Korisnik će dobiti email', + 'Email subject' => 'Predmet email-a', + 'Date' => 'Datum', + 'By @%s on Bitbucket' => 'Od @%s na Bitbucket', + 'Bitbucket Issue' => 'Bitbucket problem', + 'Commit made by @%s on Bitbucket' => 'Commit-ao @%s na Bitbucket', + 'Commit made by @%s on Github' => 'Commit-ao @%s na Github', + 'By @%s on Github' => '@%s na Github', + 'Commit made by @%s on Gitlab' => 'Commit-ao @%s na Gitlab', + 'Add a comment log when moving the task between columns' => 'Dodaj komentar u dnevnik kada se pomjeri zadatak između kolona', + 'Move the task to another column when the category is changed' => 'Pomjeri zadatak u drugu kolonu kada je kategorija promijenjena', + 'Send a task by email to someone' => 'Pošalji zadatak nekome emailom', + 'Reopen a task' => 'Ponovo otvori zadatak', + 'Bitbucket issue opened' => 'Bitbucket: otvoren problem', + 'Bitbucket issue closed' => 'Bitbucket: zatvoren problem', + 'Bitbucket issue reopened' => 'Bitbucket: problem ponovo otvoren', + 'Bitbucket issue assignee change' => 'Bitbucket: promijenjen izvršilac problema', + 'Bitbucket issue comment created' => 'Bitbucket: dodan komentar na problemu', + 'Column change' => 'Promijena kolone', + 'Position change' => 'Promjena pozicije', + 'Swimlane change' => 'Promjena swimline trake', + 'Assignee change' => 'Promijenjen izvršilac', + '[%s] Overdue tasks' => '[%s] Zaostali zadaci', + 'Notification' => 'Obavještenja', + '%s moved the task #%d to the first swimlane' => '%s je premjestio zadatak #%d u prvu swimline traku', + '%s moved the task #%d to the swimlane "%s"' => '%s je premjestio zadatak #%d u swimline traku "%s"', + 'Swimlane' => 'Swimline traka', + 'Gravatar' => 'Gravatar', + '%s moved the task %s to the first swimlane' => '%s je premjestio zadatak %s u prvi swimline traku', + '%s moved the task %s to the swimlane "%s"' => '%s je premjestio zadatak %s u swimline traku "%s"', + 'This report contains all subtasks information for the given date range.' => 'Ovaj izvještaj sadržava sve informacije o pod-zadacima za dati period', + 'This report contains all tasks information for the given date range.' => 'Ovaj izvještaj sadržava sve informacije o zadacima u datom periodu', + 'Project activities for %s' => 'Aktivnosti projekta za %s', + 'view the board on Kanboard' => 'pregled ploče na Kanboard-u', + 'The task have been moved to the first swimlane' => 'Zadatak je premješten u prvu swimline traku', + 'The task have been moved to another swimlane:' => 'Zadatak je premješten u drugu swimline traku', + 'Overdue tasks for the project "%s"' => 'Zadaci u kašnjenju za projekat "%s"', + 'New title: %s' => 'Novi naslov: %s', + 'The task is not assigned anymore' => 'Zadatak nema više izvršioca', + 'New assignee: %s' => 'Novi izvršilac: %s', + 'There is no category now' => 'Sada nema kategorije', + 'New category: %s' => 'Nova kategorija: %s', + 'New color: %s' => 'Nova boja: %s', + 'New complexity: %d' => 'Nova složenost: %d', + 'The due date have been removed' => 'Datum završetka je ukloljen', + 'There is no description anymore' => 'Nema više opisa', + 'Recurrence settings have been modified' => 'Promijenjene postavke za ponavljajuće zadatke', + 'Time spent changed: %sh' => 'Utrošeno vrijeme je promijenjeno: %sh', + 'Time estimated changed: %sh' => 'Očekivano vrijeme je promijenjeno: %sh', + 'The field "%s" have been updated' => 'Polje "%s" je ažurirano', + 'The description have been modified' => 'Promijenjen opis', + 'Do you really want to close the task "%s" as well as all subtasks?' => 'Da li zaista želiš zatvoriti zadatak "%s" kao i sve pod-zadatke?', + 'Swimlane: %s' => 'Swimline traka: %s', + 'I want to receive notifications for:' => 'Želim dobijati obavještenja za:', + 'All tasks' => 'Sve zadatke', + 'Only for tasks assigned to me' => 'Samo za zadatke na kojima sam izvršilac', + 'Only for tasks created by me' => 'Samo za zadatke koje sam ja napravio', + 'Only for tasks created by me and assigned to me' => 'Samo za zadatke koje sam ja napravio i na kojima sam izvršilac', + '%A' => '%A', + '%b %e, %Y, %k:%M %p' => '%b %e, %Y, %k:%M %p', + 'New due date: %B %e, %Y' => 'Novi datum završetka: %B %e, %Y', + 'Start date changed: %B %e, %Y' => 'Početni datum promijenjen: %B %e, %Y', + '%k:%M %p' => '%k:%M %p', + '%%Y-%%m-%%d' => '%%Y-%%m-%%d', + 'Total for all columns' => 'Ukupno za sve kolone', + 'You need at least 2 days of data to show the chart.' => 'Da bi se prikazao ovaj grafik potrebni su podaci iz najmanje posljednja dva dana.', + '<15m' => '<15m', + '<30m' => '<30m', + 'Stop timer' => 'Zaustavi tajmer', + 'Start timer' => 'Pokreni tajmer', + 'Add project member' => 'Dodaj člana projekta', + 'Enable notifications' => 'Omogući obavještenja', + 'My activity stream' => 'Tok mojih aktivnosti', + 'My calendar' => 'Moj kalendar', + 'Search tasks' => 'Pretraga zadataka', + 'Back to the calendar' => 'Vrati na kalendar', + 'Filters' => 'Filteri', + 'Reset filters' => 'Vrati filtere na početno', + 'My tasks due tomorrow' => 'Moji zadaci koje treba završiti sutra', + 'Tasks due today' => 'Zadaci koje treba završiti danas', + 'Tasks due tomorrow' => 'Zadaci koje treba završiti sutra', + 'Tasks due yesterday' => 'Zadaci koje je trebalo završiti jučer', + 'Closed tasks' => 'Zatvoreni zadaci', + 'Open tasks' => 'Otvoreni zadaci', + 'Not assigned' => 'Bez izvršioca', + 'View advanced search syntax' => 'Vidi naprednu sintaksu pretrage', + 'Overview' => 'Opšti pregled', + '%b %e %Y' => '%b %e %Y', + 'Board/Calendar/List view' => 'Pregle Table/Kalendara/Liste', + 'Switch to the board view' => 'Promijeni da vidim tablu', + 'Switch to the calendar view' => 'Promijeni da vidim kalendar', + 'Switch to the list view' => 'Promijeni da vidim listu', + 'Go to the search/filter box' => 'Idi na kutiju s pretragom/filterima', + 'There is no activity yet.' => 'Još uvijek nema aktivnosti.', + 'No tasks found.' => 'Zadaci nisu pronađeni.', + 'Keyboard shortcut: "%s"' => 'Prečica tastature: "%s"', + 'List' => 'Lista', + 'Filter' => 'Filter', + 'Advanced search' => 'Napredna pretraga', + 'Example of query: ' => 'Primjer za upit', + 'Search by project: ' => 'Pretraga po projektu', + 'Search by column: ' => 'Pretraga po koloni', + 'Search by assignee: ' => 'Pretraga po izvršiocu', + 'Search by color: ' => 'Pretraga po boji', + 'Search by category: ' => 'Pretraga po kategoriji', + 'Search by description: ' => 'Pretraga po opisu', + 'Search by due date: ' => 'Pretraga po datumu završetka', + 'Lead and Cycle time for "%s"' => 'Vrijeme upravljanje i vremenski ciklus za "%s"', + 'Average time spent into each column for "%s"' => 'Prosjek utrošenog vremena u svakoj koloni za "%s"', + 'Average time spent into each column' => 'Prosjek utrošenog vrmena u svakoj koloni', + 'Average time spent' => 'Prosjek utrošenog vremena', + 'This chart show the average time spent into each column for the last %d tasks.' => 'Ovaj grafik pokazuje prosjek utrošenog vremena u svakoj koloni za posljednjih %d zadataka.', + 'Average Lead and Cycle time' => 'Prosjek vremena upravljanja i vremenskog ciklusa', + 'Average lead time: ' => 'Prosjek vremena upravljanja', + 'Average cycle time: ' => 'Prosjek vremenskog ciklusa', + 'Cycle Time' => 'Vremenski ciklus', + 'Lead Time' => 'Vrijeme upravljanja', + 'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Ovaj grafik pokazuje prosjek vremena vođenja i vremenskog ciklusa za posljednjih %d zadataka tokom vremena.', + 'Average time into each column' => 'Prosječno vrijeme u svakoj koloni', + 'Lead and cycle time' => 'Vrijeme vođenja i vremenski ciklus', + 'Google Authentication' => 'Google autentifikacija', + 'Help on Google authentication' => 'Pomoć na Google autentifikacija', + 'Github Authentication' => 'Github autentifikacija', + 'Help on Github authentication' => 'Pomoć na Github autentifikacija', + 'Lead time: ' => 'Vrijeme vođenja: ', + 'Cycle time: ' => 'Vremenski ciklus: ', + 'Time spent into each column' => 'Utrošeno vrijeme u svakoj koloni', + 'The lead time is the duration between the task creation and the completion.' => 'Vrijeme vođenja je vrijeme koje je proteklo između otvaranja i zatvaranja zadatka.', + 'The cycle time is the duration between the start date and the completion.' => 'Vremenski ciklus je vrijeme koje je proteklo između početka i završetka rada na zadatku.', + 'If the task is not closed the current time is used instead of the completion date.' => 'Ako zadatak nije zatvoren trenutno vrijeme je iskorišteno umjesto datuma završetka.', + 'Set automatically the start date' => 'Automatski postavi početno vrijeme', + 'Edit Authentication' => 'Uredi autentifikaciju', + 'Google Id' => 'Google Id', + 'Github Id' => 'Github Id', + 'Remote user' => 'Vanjski korisnik', + 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Vanjski korisnik ne čuva šifru u Kanboard bazi, npr: LDAP, Google i Github korisnički računi.', + 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Ako ste označili kvadratić "Zabrani prijavnu formu", unos pristupnih podataka u prijavnoj formi će biti ignorisan.', + 'By @%s on Gitlab' => 'Od @%s s Gitlab-om', + 'Gitlab issue comment created' => 'Gitlab: dodan komentar za problem', + 'New remote user' => 'Novi vanjski korisnik', + 'New local user' => 'Novi lokalni korisnik', + 'Default task color' => 'Podrazumijevana boja zadatka', + 'Hide sidebar' => 'Sakri bočnu traku', + 'Expand sidebar' => 'Proširi bočnu traku', + 'This feature does not work with all browsers.' => 'Ovaj funkcionalnost ne radi na svim internet pretraživačima.', + 'There is no destination project available.' => 'Nema definisanog odredišta za projekat.', + 'Trigger automatically subtask time tracking' => 'Okidač za automatsko vremensko praćenje za pod-zadatke', + 'Include closed tasks in the cumulative flow diagram' => 'Obuhvati zatvorene zadatke u kumulativnom dijagramu toka', + 'Current swimlane: %s' => 'Trenutna swimline traka: %s', + 'Current column: %s' => 'Trenutna kolona: %s', + 'Current category: %s' => 'Trenutna kategorija: %s', + 'no category' => 'bez kategorije', + 'Current assignee: %s' => 'Trenutni izvršilac: %s', + 'not assigned' => 'bez ivršioca', + 'Author:' => 'Autor:', + 'contributors' => 'saradnici', + 'License:' => 'Licenca:', + 'License' => 'Licenca', + 'Project Administrator' => 'Administrator projekta', + 'Enter the text below' => 'Unesi tekst ispod', + 'Gantt chart for %s' => 'Gantogram za %s', + 'Sort by position' => 'Sortiraj po poziciji', + 'Sort by date' => 'Sortiraj po datumu', + 'Add task' => 'Dodaj zadatak', + 'Start date:' => 'Početno vrijeme:', + 'Due date:' => 'Vrijeme do kada treba završiti:', + 'There is no start date or due date for this task.' => 'Nema početnog datuma ili datuma do kada treba završiti ovaj zadatak.', + 'Moving or resizing a task will change the start and due date of the task.' => 'Premještanje ili promjena veličine zadatka će promijeniti datum početka i datum do kada treba završiti zadatak.', + 'There is no task in your project.' => 'Nema zadataka u tvom projektu.', + 'Gantt chart' => 'Gantogram', + 'People who are project managers' => 'Osobe koji su menadžeri projekta', + 'People who are project members' => 'Osobe koje su članovi projekta', + 'NOK - Norwegian Krone' => 'NOK - Norveška kruna', + 'Show this column' => 'Prikaži ovu kolonu', + 'Hide this column' => 'Sakrij ovu kolonu', + 'open file' => 'otvori fajl', + 'End date' => 'Datum završetka', + 'Users overview' => 'Opšti pregled korisnika', + 'Managers' => 'Menadžeri', + 'Members' => 'Članovi', + 'Shared project' => 'Dijeljeni projekti', + 'Project managers' => 'Menadžeri projekta', + 'Project members' => 'Članovi projekta', + 'Gantt chart for all projects' => 'Gantogram za sve projekte', + 'Projects list' => 'Lista projekata', + 'Gantt chart for this project' => 'Gantogram za ovaj projekat', + 'Project board' => 'Tabla projekta', + 'End date:' => 'Datum završetka:', + 'There is no start date or end date for this project.' => 'Nema početnog ili krajnjeg datuma za ovaj projekat.', + 'Projects Gantt chart' => 'Gantogram projekata', + 'Start date: %s' => 'Početni datum: %s', + 'End date: %s' => 'Datum završetka: %s', + 'Link type' => 'Tip veze', + 'Change task color when using a specific task link' => 'Promijeni boju zadatka kada se koristi određena veza na zadatku', + 'Task link creation or modification' => 'Veza na zadatku je napravljena ili izmijenjena', + 'Login with my Gitlab Account' => 'Prijava s mojim Gitlab korisničkim računom', + 'Milestone' => 'Prekretnica', + 'Gitlab Authentication' => 'Gitlab autentifikacija', + 'Help on Gitlab authentication' => 'Pomoć na Gitlab autentifikacija', + 'Gitlab Id' => 'Gitlab Id', + 'Gitlab Account' => 'Gitlab korisnički račun', + 'Link my Gitlab Account' => 'Veza s mojim Gitlab korisničkim računom', + 'Unlink my Gitlab Account' => 'Prekini vezu s mojim Gitlab korisničkim računom', + 'Documentation: %s' => 'Dokumentacija: %s', + 'Switch to the Gantt chart view' => 'Promijeni u gantogram pregled', + 'Reset the search/filter box' => 'Vrati na početno pretragu/filtere', + 'Documentation' => 'Dokumentacija', + 'Table of contents' => 'Sadržaj', + 'Gantt' => 'Gantogram', + 'Help with project permissions' => 'Pomoć s pravima nad projektom', + 'Author' => 'Autor', + 'Version' => 'Verzija', + 'Plugins' => 'Dodaci', + 'There is no plugin loaded.' => 'Nema učitanih dodataka.', + 'Set maximum column height' => 'Postavi maksimalnu visinu kolone', + 'Remove maximum column height' => 'Ukloni maksimalnu visinu kolone', + 'My notifications' => 'Moja obavještenja', + 'Custom filters' => 'Prilagođeni filteri', + 'Your custom filter have been created successfully.' => 'Tvoj prilagođeni filter je uspješno napravljen.', + 'Unable to create your custom filter.' => 'Nemoguće napraviti prilagođeni filter.', + 'Custom filter removed successfully.' => 'Prilagođeni filter uspješno uklonjen.', + 'Unable to remove this custom filter.' => 'Nemoguće ukloniti prilagođeni filter.', + 'Edit custom filter' => 'Uredi prilagođeni filter', + 'Your custom filter have been updated successfully.' => 'Prilagođeni filter uspješno ažuriran.', + 'Unable to update custom filter.' => 'Nemoguće ažurirati prilagođeni filter', + 'Web' => 'Web', + 'New attachment on task #%d: %s' => 'Novi priložak na zadatku #%d: %s', + 'New comment on task #%d' => 'Novi komentar na zadatku #%d', + 'Comment updated on task #%d' => 'Ažuriran komentar na zadatku #%d', + 'New subtask on task #%d' => 'Novi pod-zadatak na zadatku #%d', + 'Subtask updated on task #%d' => 'Pod-zadatak ažuriran na zadatku #%d', + 'New task #%d: %s' => 'Novi zadatak #%d: %s', + 'Task updated #%d' => 'Zadatak ažuriran #%d', + 'Task #%d closed' => 'Zadatak #%d zatvoren', + 'Task #%d opened' => 'Zadatak #%d otvoren', + 'Column changed for task #%d' => 'Promijenjena kolona za zadatak #%d', + 'New position for task #%d' => 'Nova pozicija za zadatak #%d', + 'Swimlane changed for task #%d' => 'Swimline traka promijenjena za zadatak #%d', + 'Assignee changed on task #%d' => 'Promijenjen izvršilac na zadatku #%d', + '%d overdue tasks' => '%d zadataka kasni', + 'Task #%d is overdue' => 'Zadatak #%d kasni', + 'No new notifications.' => 'Nema novi obavještenja.', + 'Mark all as read' => 'Označi sve kao pročitano', + 'Mark as read' => 'Označi kao pročitano', + 'Total number of tasks in this column across all swimlanes' => 'Ukupan broj zadataka u ovoj koloni u svim swimline trakama', + 'Collapse swimlane' => 'Skupi swimline trake', + 'Expand swimlane' => 'Proširi swimline trake', + 'Add a new filter' => 'Dodaj novi filter', + 'Share with all project members' => 'Podijeli s svim članovima projekta', + 'Shared' => 'Podijeljeno', + 'Owner' => 'Vlasnik', + 'Unread notifications' => 'Nepročitana obavještenja', + 'My filters' => 'Moji filteri', + 'Notification methods:' => 'Metode obavještenja:', + 'Import tasks from CSV file' => 'Uvezi zadatke putem CSV fajla', + 'Unable to read your file' => 'Nemoguće pročitati fajl', + '%d task(s) have been imported successfully.' => '%d zadataka uspješno uvezeno.', + 'Nothing have been imported!' => 'Ništa nije uvezeno!', + 'Import users from CSV file' => 'Uvezi korisnike putem CSV fajla', + '%d user(s) have been imported successfully.' => '%d korisnika uspješno uvezeno.', + 'Comma' => 'Zarez', + 'Semi-colon' => 'Tačka-zarez', + 'Tab' => 'Tab', + 'Vertical bar' => 'Vertikalna traka', + 'Double Quote' => 'Dvostruki navodnici', + 'Single Quote' => 'Jednostruki navodnici', + '%s attached a file to the task #%d' => '%s file je prirodan zadatku #%d', + 'There is no column or swimlane activated in your project!' => 'Nema kolone ili swimline trake aktivirane za ovaj projekat!', + 'Append filter (instead of replacement)' => 'Dodaj filter (umjesto zamjene postojećeg)', + 'Append/Replace' => 'Dodaj/Zamijeni', + 'Append' => 'Dodaj', + 'Replace' => 'Zamijeni', + 'There is no notification method registered.' => 'Nema registrovanih metoda za obavještenja', + 'Import' => 'Uvoz', + 'change sorting' => 'Promijeni sortiranje', + 'Tasks Importation' => 'Uvoz zadataka', + 'Delimiter' => 'Djelilac', + 'Enclosure' => 'Prilog', + 'CSV File' => 'CSV File', + 'Instructions' => 'Uputstva', + 'Your file must use the predefined CSV format' => 'File mora biti predefinisani CSV format', + 'Your file must be encoded in UTF-8' => 'File mora biti u UTF-8 kodu', + 'The first row must be the header' => 'Prvi red mora biti zaglavlje', + 'Duplicates are not verified for you' => 'Dipliciranje nisu potvrđena', + 'The due date must use the ISO format: YYYY-MM-DD' => 'Datum do kog se treba izvršiti mora biti u ISO formatu: GGGG-MM-DD', + 'Download CSV template' => 'Preuzmi CSV šablon', + 'No external integration registered.' => 'Nema registrovanih vanjskih integracija.', + 'Duplicates are not imported' => 'Duplikati nisu uvezeni', + 'Usernames must be lowercase and unique' => 'Korisničko ime mora biti malim slovima i jedinstveno', + 'Passwords will be encrypted if present' => 'Šifra će biti kriptovana', + // '%s attached a new file to the task %s' => '', + // 'Assign automatically a category based on a link' => '', + 'BAM - Konvertibile Mark' => 'BAM - Konvertibilna marka', + // 'Assignee Username' => '', + // 'Assignee Name' => '', +); diff --git a/app/Locale/cs_CZ/translations.php b/app/Locale/cs_CZ/translations.php index 0134acc4..5d6af0b6 100644 --- a/app/Locale/cs_CZ/translations.php +++ b/app/Locale/cs_CZ/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/da_DK/translations.php b/app/Locale/da_DK/translations.php index f36c08a2..a5ac1d39 100644 --- a/app/Locale/da_DK/translations.php +++ b/app/Locale/da_DK/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/de_DE/translations.php b/app/Locale/de_DE/translations.php index 03eb387e..13fa7542 100644 --- a/app/Locale/de_DE/translations.php +++ b/app/Locale/de_DE/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php index e491df6c..02c6961a 100644 --- a/app/Locale/es_ES/translations.php +++ b/app/Locale/es_ES/translations.php @@ -1065,4 +1065,7 @@ return array( 'Passwords will be encrypted if present' => 'Las contraseñas serán cifradas si es que existen', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/fi_FI/translations.php b/app/Locale/fi_FI/translations.php index 15581c2e..bff0f054 100644 --- a/app/Locale/fi_FI/translations.php +++ b/app/Locale/fi_FI/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php index 095f15c6..36ecf2e9 100644 --- a/app/Locale/fr_FR/translations.php +++ b/app/Locale/fr_FR/translations.php @@ -1068,4 +1068,7 @@ return array( '%s attached a new file to the task %s' => '%s a attaché un nouveau fichier à la tâche %s', 'Link type' => 'Type de lien', 'Assign automatically a category based on a link' => 'Assigner automatiquement une catégorie en fonction d\'un lien', + 'BAM - Konvertibile Mark' => 'BAM - Mark convertible', + 'Assignee Username' => 'Utilisateur assigné', + 'Assignee Name' => 'Nom de l\'assigné', ); diff --git a/app/Locale/hu_HU/translations.php b/app/Locale/hu_HU/translations.php index 050528aa..1a34a5b9 100644 --- a/app/Locale/hu_HU/translations.php +++ b/app/Locale/hu_HU/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/id_ID/translations.php b/app/Locale/id_ID/translations.php index 49444f10..3e80025a 100644 --- a/app/Locale/id_ID/translations.php +++ b/app/Locale/id_ID/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/it_IT/translations.php b/app/Locale/it_IT/translations.php index aa842c36..7fd39d9d 100644 --- a/app/Locale/it_IT/translations.php +++ b/app/Locale/it_IT/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/ja_JP/translations.php b/app/Locale/ja_JP/translations.php index c77b3b11..8447113c 100644 --- a/app/Locale/ja_JP/translations.php +++ b/app/Locale/ja_JP/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/nb_NO/translations.php b/app/Locale/nb_NO/translations.php index 48cf482e..b0fa0647 100644 --- a/app/Locale/nb_NO/translations.php +++ b/app/Locale/nb_NO/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/nl_NL/translations.php b/app/Locale/nl_NL/translations.php index 9fbb95d7..eaeca452 100644 --- a/app/Locale/nl_NL/translations.php +++ b/app/Locale/nl_NL/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/pl_PL/translations.php b/app/Locale/pl_PL/translations.php index 063496c3..7419e134 100644 --- a/app/Locale/pl_PL/translations.php +++ b/app/Locale/pl_PL/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/pt_BR/translations.php b/app/Locale/pt_BR/translations.php index 023897e1..f03be30e 100644 --- a/app/Locale/pt_BR/translations.php +++ b/app/Locale/pt_BR/translations.php @@ -1065,4 +1065,7 @@ return array( 'Passwords will be encrypted if present' => 'Senhas serão encriptadas, se presentes', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/pt_PT/translations.php b/app/Locale/pt_PT/translations.php index 16e08ba5..5e6c57d1 100644 --- a/app/Locale/pt_PT/translations.php +++ b/app/Locale/pt_PT/translations.php @@ -1065,4 +1065,7 @@ return array( 'Passwords will be encrypted if present' => 'Senhas serão encriptadas se presentes', '%s attached a new file to the task %s' => '%s anexou um novo ficheiro à tarefa %s', 'Assign automatically a category based on a link' => 'Assignar automáticamente a categoria baseada num link', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php index cacdbfa3..e7f2d7a2 100644 --- a/app/Locale/ru_RU/translations.php +++ b/app/Locale/ru_RU/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/sr_Latn_RS/translations.php b/app/Locale/sr_Latn_RS/translations.php index 74b60f86..af785f91 100644 --- a/app/Locale/sr_Latn_RS/translations.php +++ b/app/Locale/sr_Latn_RS/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php index 9587538f..188b2bd4 100644 --- a/app/Locale/sv_SE/translations.php +++ b/app/Locale/sv_SE/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php index 77affbc5..d180c5e5 100644 --- a/app/Locale/th_TH/translations.php +++ b/app/Locale/th_TH/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/tr_TR/translations.php b/app/Locale/tr_TR/translations.php index d92b768c..fca425eb 100644 --- a/app/Locale/tr_TR/translations.php +++ b/app/Locale/tr_TR/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Locale/zh_CN/translations.php b/app/Locale/zh_CN/translations.php index a3d93baf..7642e01e 100644 --- a/app/Locale/zh_CN/translations.php +++ b/app/Locale/zh_CN/translations.php @@ -1065,4 +1065,7 @@ return array( // 'Passwords will be encrypted if present' => '', // '%s attached a new file to the task %s' => '', // 'Assign automatically a category based on a link' => '', + // 'BAM - Konvertibile Mark' => '', + // 'Assignee Username' => '', + // 'Assignee Name' => '', ); diff --git a/app/Model/Config.php b/app/Model/Config.php index 6a6f8a5a..273f884a 100644 --- a/app/Model/Config.php +++ b/app/Model/Config.php @@ -35,6 +35,7 @@ class Config extends Setting 'RSD' => t('RSD - Serbian dinar'), 'SEK' => t('SEK - Swedish Krona'), 'NOK' => t('NOK - Norwegian Krone'), + 'BAM' => t('BAM - Konvertibile Mark'), ); } @@ -69,6 +70,7 @@ class Config extends Setting // Sorted by value $languages = array( 'id_ID' => 'Bahasa Indonesia', + 'bs_BA' => 'Bosanski', 'cs_CZ' => 'Čeština', 'da_DK' => 'Dansk', 'de_DE' => 'Deutsch', diff --git a/tests/units/Model/ProjectDailyStatsTest.php b/tests/units/Model/ProjectDailyStatsTest.php index c17017c9..677a97d0 100644 --- a/tests/units/Model/ProjectDailyStatsTest.php +++ b/tests/units/Model/ProjectDailyStatsTest.php @@ -29,12 +29,12 @@ class ProjectDailyStatsTest extends Base $metrics = $pds->getRawMetrics(1, date('Y-m-d', strtotime('-1days')), date('Y-m-d')); $expected = array( array( - 'day' => '2015-11-20', + 'day' => date('Y-m-d', strtotime('-1days')), 'avg_lead_time' => 0, 'avg_cycle_time' => 43200, ), array( - 'day' => '2015-11-21', + 'day' => date('Y-m-d'), 'avg_lead_time' => 0, 'avg_cycle_time' => 43200, ) -- cgit v1.2.3 From e582d4047b061f0c17e6366fed2bf1cabd624c10 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Wed, 25 Nov 2015 22:06:39 -0500 Subject: Add groups (teams) --- ChangeLog | 10 ++ app/Controller/Group.php | 255 ++++++++++++++++++++++++++++++++++ app/Model/Group.php | 151 ++++++++++++++++++++ app/Model/GroupMember.php | 95 +++++++++++++ app/Schema/Mysql.php | 24 +++- app/Schema/Postgres.php | 23 ++- app/Schema/Sqlite.php | 37 ++++- app/ServiceProvider/ClassProvider.php | 2 + app/Template/group/associate.php | 25 ++++ app/Template/group/create.php | 19 +++ app/Template/group/dissociate.php | 19 +++ app/Template/group/edit.php | 22 +++ app/Template/group/index.php | 45 ++++++ app/Template/group/remove.php | 19 +++ app/Template/group/users.php | 44 ++++++ app/Template/user/create_local.php | 2 +- app/Template/user/index.php | 1 + composer.json | 2 +- composer.lock | 82 ++++++----- tests/units/Model/GroupMemberTest.php | 76 ++++++++++ tests/units/Model/GroupTest.php | 82 +++++++++++ 21 files changed, 994 insertions(+), 41 deletions(-) create mode 100644 app/Controller/Group.php create mode 100644 app/Model/Group.php create mode 100644 app/Model/GroupMember.php create mode 100644 app/Template/group/associate.php create mode 100644 app/Template/group/create.php create mode 100644 app/Template/group/dissociate.php create mode 100644 app/Template/group/edit.php create mode 100644 app/Template/group/index.php create mode 100644 app/Template/group/remove.php create mode 100644 app/Template/group/users.php create mode 100644 tests/units/Model/GroupMemberTest.php create mode 100644 tests/units/Model/GroupTest.php (limited to 'tests/units') diff --git a/ChangeLog b/ChangeLog index 56bbcdf4..ed94807d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,13 @@ +Version 1.0.22 (unreleased) +--------------------------- + +New features: + +* User groups (Teams) +* Pluggable authentication and authorization system (Work in progress) +* Add new project role Viewer (Work in progress) +* Assign project permissions to a group (Work in progress) + Version 1.0.21 -------------- diff --git a/app/Controller/Group.php b/app/Controller/Group.php new file mode 100644 index 00000000..4e81f6c1 --- /dev/null +++ b/app/Controller/Group.php @@ -0,0 +1,255 @@ +paginator + ->setUrl('group', 'index') + ->setMax(30) + ->setOrder('name') + ->setQuery($this->group->getQuery()) + ->calculate(); + + $this->response->html($this->template->layout('group/index', array( + 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'title' => t('Groups').' ('.$paginator->getTotal().')', + 'paginator' => $paginator, + ))); + } + + /** + * List all users + * + * @access public + */ + public function users() + { + $group_id = $this->request->getIntegerParam('group_id'); + $group = $this->group->getById($group_id); + + $paginator = $this->paginator + ->setUrl('group', 'users') + ->setMax(30) + ->setOrder('username') + ->setQuery($this->groupMember->getQuery($group_id)) + ->calculate(); + + $this->response->html($this->template->layout('group/users', array( + 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'title' => t('Members of %s', $group['name']).' ('.$paginator->getTotal().')', + 'paginator' => $paginator, + 'group' => $group, + ))); + } + + /** + * Display a form to create a new group + * + * @access public + */ + public function create(array $values = array(), array $errors = array()) + { + $this->response->html($this->template->layout('group/create', array( + 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'errors' => $errors, + 'values' => $values, + 'title' => t('New group') + ))); + } + + /** + * Validate and save a new group + * + * @access public + */ + public function save() + { + $values = $this->request->getValues(); + list($valid, $errors) = $this->group->validateCreation($values); + + if ($valid) { + if ($this->group->create($values['name']) !== false) { + $this->flash->success(t('Group created successfully.')); + $this->response->redirect($this->helper->url->to('group', 'index')); + } else { + $this->flash->failure(t('Unable to create your group.')); + } + } + + $this->create($values, $errors); + } + + /** + * Display a form to update a group + * + * @access public + */ + public function edit(array $values = array(), array $errors = array()) + { + if (empty($values)) { + $values = $this->group->getById($this->request->getIntegerParam('group_id')); + } + + $this->response->html($this->template->layout('group/edit', array( + 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'errors' => $errors, + 'values' => $values, + 'title' => t('Edit group') + ))); + } + + /** + * Validate and save a group + * + * @access public + */ + public function update() + { + $values = $this->request->getValues(); + list($valid, $errors) = $this->group->validateModification($values); + + if ($valid) { + if ($this->group->update($values) !== false) { + $this->flash->success(t('Group updated successfully.')); + $this->response->redirect($this->helper->url->to('group', 'index')); + } else { + $this->flash->failure(t('Unable to update your group.')); + } + } + + $this->edit($values, $errors); + } + + /** + * Form to associate a user to a group + * + * @access public + */ + public function associate(array $values = array(), array $errors = array()) + { + $group_id = $this->request->getIntegerParam('group_id'); + $group = $this->group->getbyId($group_id); + + if (empty($values)) { + $values['group_id'] = $group_id; + } + + $this->response->html($this->template->layout('group/associate', array( + 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'users' => $this->user->prepareList($this->groupMember->getNotMembers($group_id)), + 'group' => $group, + 'errors' => $errors, + 'values' => $values, + 'title' => t('Add group member to "%s"', $group['name']), + ))); + } + + /** + * Add user to a group + * + * @access public + */ + public function addUser() + { + $values = $this->request->getValues(); + + if (isset($values['group_id']) && isset($values['user_id'])) { + if ($this->groupMember->addUser($values['group_id'], $values['user_id'])) { + $this->flash->success(t('Group member added successfully.')); + $this->response->redirect($this->helper->url->to('group', 'users', array('group_id' => $values['group_id']))); + } else { + $this->flash->failure(t('Unable to add group member.')); + } + } + + $this->associate($values); + } + + /** + * Confirmation dialog to remove a user from a group + * + * @access public + */ + public function dissociate() + { + $group_id = $this->request->getIntegerParam('group_id'); + $user_id = $this->request->getIntegerParam('user_id'); + $group = $this->group->getById($group_id); + $user = $this->user->getById($user_id); + + $this->response->html($this->template->layout('group/dissociate', array( + 'group' => $group, + 'user' => $user, + 'title' => t('Remove a user from group "%s', $group['name']), + ))); + } + + /** + * Remove a user from a group + * + * @access public + */ + public function removeUser() + { + $this->checkCSRFParam(); + $group_id = $this->request->getIntegerParam('group_id'); + $user_id = $this->request->getIntegerParam('user_id'); + + if ($this->groupMember->removeUser($group_id, $user_id)) { + $this->flash->success(t('User removed successfully from this group.')); + } else { + $this->flash->failure(t('Unable to remove this user from the group.')); + } + + $this->response->redirect($this->helper->url->to('group', 'users', array('group_id' => $group_id))); + } + + /** + * Confirmation dialog to remove a group + * + * @access public + */ + public function confirm() + { + $group_id = $this->request->getIntegerParam('group_id'); + $group = $this->group->getById($group_id); + + $this->response->html($this->template->layout('group/remove', array( + 'group' => $group, + 'title' => t('Remove group'), + ))); + } + + /** + * Remove a group + * + * @access public + */ + public function remove() + { + $this->checkCSRFParam(); + $group_id = $this->request->getIntegerParam('group_id'); + + if ($this->group->remove($group_id)) { + $this->flash->success(t('Group removed successfully.')); + } else { + $this->flash->failure(t('Unable to remove this group.')); + } + + $this->response->redirect($this->helper->url->to('group', 'index')); + } +} diff --git a/app/Model/Group.php b/app/Model/Group.php new file mode 100644 index 00000000..82a8887b --- /dev/null +++ b/app/Model/Group.php @@ -0,0 +1,151 @@ +db->table(self::TABLE); + } + + /** + * Get a specific group by id + * + * @access public + * @param integer $group_id + * @return array + */ + public function getById($group_id) + { + return $this->getQuery()->eq('id', $group_id)->findOne(); + } + + /** + * Get all groups + * + * @access public + * @return array + */ + public function getAll() + { + return $this->getQuery()->asc('name')->findAll(); + } + + /** + * Remove a group + * + * @access public + * @param integer $group_id + * @return array + */ + public function remove($group_id) + { + return $this->db->table(self::TABLE)->eq('id', $group_id)->remove(); + } + + /** + * Create a new group + * + * @access public + * @param string $name + * @param string $external_id + * @return integer|boolean + */ + public function create($name, $external_id = '') + { + return $this->persist(self::TABLE, array( + 'name' => $name, + 'external_id' => $external_id, + )); + } + + /** + * Update existing group + * + * @access public + * @param array $values + * @return boolean + */ + public function update(array $values) + { + return $this->db->table(self::TABLE)->eq('id', $values['id'])->update($values); + } + + /** + * Validate creation + * + * @access public + * @param array $values Form values + * @return array $valid, $errors [0] = Success or not, [1] = List of errors + */ + public function validateCreation(array $values) + { + $v = new Validator($values, $this->commonValidationRules()); + + return array( + $v->execute(), + $v->getErrors() + ); + } + + /** + * Validate modification + * + * @access public + * @param array $values Form values + * @return array $valid, $errors [0] = Success or not, [1] = List of errors + */ + public function validateModification(array $values) + { + $rules = array( + new Validators\Required('id', t('The id is required')), + ); + + $v = new Validator($values, array_merge($rules, $this->commonValidationRules())); + + return array( + $v->execute(), + $v->getErrors() + ); + } + + /** + * Common validation rules + * + * @access private + * @return array + */ + private function commonValidationRules() + { + return array( + new Validators\Required('name', t('The name is required')), + new Validators\MaxLength('name', t('The maximum length is %d characters', 100), 100), + new Validators\Unique('name', t('The name must be unique'), $this->db->getConnection(), self::TABLE, 'id'), + new Validators\MaxLength('external_id', t('The maximum length is %d characters', 255), 255), + new Validators\Integer('id', t('This value must be an integer')), + ); + } +} diff --git a/app/Model/GroupMember.php b/app/Model/GroupMember.php new file mode 100644 index 00000000..04e9d495 --- /dev/null +++ b/app/Model/GroupMember.php @@ -0,0 +1,95 @@ +db->table(self::TABLE) + ->join(User::TABLE, 'id', 'user_id') + ->eq('group_id', $group_id); + } + + /** + * Get all users + * + * @access public + * @param integer $group_id + * @return array + */ + public function getMembers($group_id) + { + return $this->getQuery($group_id)->findAll(); + } + + /** + * Get all not members + * + * @access public + * @param integer $group_id + * @return array + */ + public function getNotMembers($group_id) + { + $subquery = $this->db->table(self::TABLE) + ->columns('user_id') + ->eq('group_id', $group_id); + + return $this->db->table(User::TABLE) + ->notInSubquery('id', $subquery) + ->findAll(); + } + + /** + * Add user to a group + * + * @access public + * @param integer $group_id + * @param integer $user_id + * @return boolean + */ + public function addUser($group_id, $user_id) + { + return $this->db->table(self::TABLE)->insert(array( + 'group_id' => $group_id, + 'user_id' => $user_id, + )); + } + + /** + * Remove user from a group + * + * @access public + * @param integer $group_id + * @param integer $user_id + * @return boolean + */ + public function removeUser($group_id, $user_id) + { + return $this->db->table(self::TABLE) + ->eq('group_id', $group_id) + ->eq('user_id', $user_id) + ->remove(); + } +} diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php index 52a73fb1..5a451c77 100644 --- a/app/Schema/Mysql.php +++ b/app/Schema/Mysql.php @@ -5,7 +5,29 @@ namespace Schema; use PDO; use Kanboard\Core\Security\Token; -const VERSION = 94; +const VERSION = 95; + +function version_95(PDO $pdo) +{ + $pdo->exec(" + CREATE TABLE groups ( + id INT NOT NULL AUTO_INCREMENT, + external_id VARCHAR(255) DEFAULT '', + name VARCHAR(100) NOT NULL UNIQUE, + PRIMARY KEY(id) + ) ENGINE=InnoDB CHARSET=utf8 + "); + + $pdo->exec(" + CREATE TABLE group_has_users ( + group_id INT NOT NULL, + user_id INT NOT NULL, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(group_id, user_id) + ) ENGINE=InnoDB CHARSET=utf8 + "); +} function version_94(PDO $pdo) { diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php index 5cd1a7d0..a3887cfb 100644 --- a/app/Schema/Postgres.php +++ b/app/Schema/Postgres.php @@ -5,7 +5,28 @@ namespace Schema; use PDO; use Kanboard\Core\Security\Token; -const VERSION = 74; +const VERSION = 75; + +function version_75(PDO $pdo) +{ + $pdo->exec(" + CREATE TABLE groups ( + id SERIAL PRIMARY KEY, + external_id VARCHAR(255) DEFAULT '', + name VARCHAR(100) NOT NULL UNIQUE + ) + "); + + $pdo->exec(" + CREATE TABLE group_has_users ( + group_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(group_id, user_id) + ) + "); +} function version_74(PDO $pdo) { diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php index fa26b158..f0510cff 100644 --- a/app/Schema/Sqlite.php +++ b/app/Schema/Sqlite.php @@ -5,7 +5,42 @@ namespace Schema; use Kanboard\Core\Security\Token; use PDO; -const VERSION = 88; +const VERSION = 89; + +function version_90(PDO $pdo) +{ + $pdo->exec(" + CREATE TABLE project_has_groups ( + group_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + role TEXT NOT NULL, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE(group_id, project_id) + ) + "); +} + +function version_89(PDO $pdo) +{ + $pdo->exec(" + CREATE TABLE groups ( + id INTEGER PRIMARY KEY, + external_id TEXT DEFAULT '', + name TEXT NOCASE NOT NULL UNIQUE + ) + "); + + $pdo->exec(" + CREATE TABLE group_has_users ( + group_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(group_id, user_id) + ) + "); +} function version_88(PDO $pdo) { diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index 9c9bc233..9ec81116 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -32,6 +32,8 @@ class ClassProvider implements ServiceProviderInterface 'Currency', 'CustomFilter', 'File', + 'Group', + 'GroupMember', 'LastLogin', 'Link', 'Notification', diff --git a/app/Template/group/associate.php b/app/Template/group/associate.php new file mode 100644 index 00000000..dc665bb3 --- /dev/null +++ b/app/Template/group/associate.php @@ -0,0 +1,25 @@ +
+ + +

+ +
+ form->csrf() ?> + form->hidden('group_id', $values) ?> + + form->label(t('User'), 'user_id') ?> + form->select('user_id', $users, $values, $errors, array('required'), 'chosen-select') ?>
+ +
+ + + url->link(t('cancel'), 'group', 'index') ?> +
+
+ +
diff --git a/app/Template/group/create.php b/app/Template/group/create.php new file mode 100644 index 00000000..696e5013 --- /dev/null +++ b/app/Template/group/create.php @@ -0,0 +1,19 @@ +
+ +
+ form->csrf() ?> + + form->label(t('Name'), 'name') ?> + form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="100"')) ?>
+ +
+ + + url->link(t('cancel'), 'group', 'index') ?> +
+
+
diff --git a/app/Template/group/dissociate.php b/app/Template/group/dissociate.php new file mode 100644 index 00000000..2b0b1af4 --- /dev/null +++ b/app/Template/group/dissociate.php @@ -0,0 +1,19 @@ +
+ +
+

+ +
+ url->link(t('Yes'), 'group', 'removeUser', array('group_id' => $group['id'], 'user_id' => $user['id']), true, 'btn btn-red') ?> + + url->link(t('cancel'), 'group', 'users', array('group_id' => $group['id'])) ?> +
+
+
diff --git a/app/Template/group/edit.php b/app/Template/group/edit.php new file mode 100644 index 00000000..4d7e5e81 --- /dev/null +++ b/app/Template/group/edit.php @@ -0,0 +1,22 @@ +
+ +
+ form->csrf() ?> + + form->hidden('id', $values) ?> + form->hidden('external_id', $values) ?> + + form->label(t('Name'), 'name') ?> + form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="100"')) ?>
+ +
+ + + url->link(t('cancel'), 'group', 'index') ?> +
+
+
diff --git a/app/Template/group/index.php b/app/Template/group/index.php new file mode 100644 index 00000000..24de02a0 --- /dev/null +++ b/app/Template/group/index.php @@ -0,0 +1,45 @@ +
+ + isEmpty()): ?> +

+ + + + + + + + + getCollection() as $group): ?> + + + + + + + +
order(t('Id'), 'id') ?>order(t('External Id'), 'external_id') ?>order(t('Name'), 'name') ?>
+ # + + e($group['external_id']) ?> + + e($group['name']) ?> + +
    +
  • url->link(t('Add group member'), 'group', 'associate', array('group_id' => $group['id'])) ?>
  • +
  • url->link(t('Users'), 'group', 'users', array('group_id' => $group['id'])) ?>
  • +
  • url->link(t('Edit'), 'group', 'edit', array('group_id' => $group['id'])) ?>
  • +
  • url->link(t('Remove'), 'group', 'confirm', array('group_id' => $group['id'])) ?>
  • +
+
+ + + +
diff --git a/app/Template/group/remove.php b/app/Template/group/remove.php new file mode 100644 index 00000000..48da91d5 --- /dev/null +++ b/app/Template/group/remove.php @@ -0,0 +1,19 @@ +
+ +
+

+ +
+ url->link(t('Yes'), 'group', 'remove', array('group_id' => $group['id']), true, 'btn btn-red') ?> + + url->link(t('cancel'), 'group', 'index') ?> +
+
+
diff --git a/app/Template/group/users.php b/app/Template/group/users.php new file mode 100644 index 00000000..56ad82cf --- /dev/null +++ b/app/Template/group/users.php @@ -0,0 +1,44 @@ +
+ + isEmpty()): ?> +

+ + + + + + + + + + getCollection() as $user): ?> + + + + + + + + +
order(t('Id'), 'id') ?>order(t('Username'), 'username') ?>order(t('Name'), 'name') ?>order(t('Email'), 'email') ?>
+ url->link('#'.$user['id'], 'user', 'show', array('user_id' => $user['id'])) ?> + + url->link($this->e($user['username']), 'user', 'show', array('user_id' => $user['id'])) ?> + + e($user['name']) ?> + + e($user['email']) ?> + + url->link(t('Remove this user'), 'group', 'dissociate', array('group_id' => $group['id'], 'user_id' => $user['id'])) ?> +
+ + + +
diff --git a/app/Template/user/create_local.php b/app/Template/user/create_local.php index 98c38f0d..6e6ca6ac 100644 --- a/app/Template/user/create_local.php +++ b/app/Template/user/create_local.php @@ -49,4 +49,4 @@ - \ No newline at end of file + diff --git a/app/Template/user/index.php b/app/Template/user/index.php index 4008b920..7c6ecc1e 100644 --- a/app/Template/user/index.php +++ b/app/Template/user/index.php @@ -5,6 +5,7 @@
  • url->link(t('New local user'), 'user', 'create') ?>
  • url->link(t('New remote user'), 'user', 'create', array('remote' => 1)) ?>
  • url->link(t('Import'), 'userImport', 'step1') ?>
  • +
  • url->link(t('View all groups'), 'group', 'index') ?>
  • diff --git a/composer.json b/composer.json index e6dcee80..64cf84a4 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "erusev/parsedown" : "1.5.4", "fabiang/xmpp" : "0.6.1", "fguillot/json-rpc" : "1.0.3", - "fguillot/picodb" : "1.0.2", + "fguillot/picodb" : "dev-master", "fguillot/simpleLogger" : "1.0.0", "fguillot/simple-validator" : "1.0.0", "league/html-to-markdown" : "~4.0", diff --git a/composer.lock b/composer.lock index 38869985..a7abe89f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "dedbdec5041e89f475dd55d5f11a9bd6", + "hash": "ccda1534fa372df2c12591d5fe72c6a8", "packages": [ { "name": "christian-riesen/base32", @@ -296,16 +296,16 @@ }, { "name": "fguillot/picodb", - "version": "v1.0.2", + "version": "dev-master", "source": { "type": "git", "url": "https://github.com/fguillot/picoDb.git", - "reference": "61f492c125d9195ce869447e2b2450adeb3b01d6" + "reference": "2db9ce62ac3aa968fbbc24ee4d418cab21b5ed3f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fguillot/picoDb/zipball/61f492c125d9195ce869447e2b2450adeb3b01d6", - "reference": "61f492c125d9195ce869447e2b2450adeb3b01d6", + "url": "https://api.github.com/repos/fguillot/picoDb/zipball/2db9ce62ac3aa968fbbc24ee4d418cab21b5ed3f", + "reference": "2db9ce62ac3aa968fbbc24ee4d418cab21b5ed3f", "shasum": "" }, "require": { @@ -329,7 +329,7 @@ ], "description": "Minimalist database query builder", "homepage": "https://github.com/fguillot/picoDb", - "time": "2015-08-27 23:33:16" + "time": "2015-11-26 02:33:54" }, { "name": "fguillot/simple-validator", @@ -454,16 +454,16 @@ }, { "name": "league/html-to-markdown", - "version": "4.0.1", + "version": "4.1.1", "source": { "type": "git", "url": "https://github.com/thephpleague/html-to-markdown.git", - "reference": "c496c27e01b9dce310e03afbcdf783347738f67b" + "reference": "01bbfe039d9b97526e3f3a3ee32543fc1d8dba00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/c496c27e01b9dce310e03afbcdf783347738f67b", - "reference": "c496c27e01b9dce310e03afbcdf783347738f67b", + "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/01bbfe039d9b97526e3f3a3ee32543fc1d8dba00", + "reference": "01bbfe039d9b97526e3f3a3ee32543fc1d8dba00", "shasum": "" }, "require": { @@ -472,13 +472,17 @@ "php": ">=5.3.3" }, "require-dev": { + "mikehaertl/php-shellcommand": "~1.1.0", "phpunit/phpunit": "4.*", "scrutinizer/ocular": "~1.1" }, + "bin": [ + "bin/html-to-markdown" + ], "type": "library", "extra": { "branch-alias": { - "dev-master": "4.1-dev" + "dev-master": "4.2-dev" } }, "autoload": { @@ -510,7 +514,7 @@ "html", "markdown" ], - "time": "2015-09-01 22:39:54" + "time": "2015-11-20 16:20:25" }, { "name": "pimple/pimple", @@ -651,16 +655,16 @@ }, { "name": "symfony/console", - "version": "v2.7.5", + "version": "v2.7.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "06cb17c013a82f94a3d840682b49425cd00a2161" + "reference": "16bb1cb86df43c90931df65f529e7ebd79636750" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/06cb17c013a82f94a3d840682b49425cd00a2161", - "reference": "06cb17c013a82f94a3d840682b49425cd00a2161", + "url": "https://api.github.com/repos/symfony/console/zipball/16bb1cb86df43c90931df65f529e7ebd79636750", + "reference": "16bb1cb86df43c90931df65f529e7ebd79636750", "shasum": "" }, "require": { @@ -669,7 +673,6 @@ "require-dev": { "psr/log": "~1.0", "symfony/event-dispatcher": "~2.1", - "symfony/phpunit-bridge": "~2.7", "symfony/process": "~2.1" }, "suggest": { @@ -686,7 +689,10 @@ "autoload": { "psr-4": { "Symfony\\Component\\Console\\": "" - } + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -704,20 +710,20 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2015-09-25 08:32:23" + "time": "2015-11-18 09:54:26" }, { "name": "symfony/event-dispatcher", - "version": "v2.7.5", + "version": "v2.7.7", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "ae4dcc2a8d3de98bd794167a3ccda1311597c5d9" + "reference": "7e2f9c31645680026c2372edf66f863fc7757af5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ae4dcc2a8d3de98bd794167a3ccda1311597c5d9", - "reference": "ae4dcc2a8d3de98bd794167a3ccda1311597c5d9", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7e2f9c31645680026c2372edf66f863fc7757af5", + "reference": "7e2f9c31645680026c2372edf66f863fc7757af5", "shasum": "" }, "require": { @@ -728,7 +734,6 @@ "symfony/config": "~2.0,>=2.0.5", "symfony/dependency-injection": "~2.6", "symfony/expression-language": "~2.6", - "symfony/phpunit-bridge": "~2.7", "symfony/stopwatch": "~2.3" }, "suggest": { @@ -744,7 +749,10 @@ "autoload": { "psr-4": { "Symfony\\Component\\EventDispatcher\\": "" - } + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -762,30 +770,27 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2015-09-22 13:49:29" + "time": "2015-10-30 20:10:21" } ], "packages-dev": [ { "name": "symfony/stopwatch", - "version": "v2.7.5", + "version": "v2.7.7", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "08dd97b3f22ab9ee658cd16e6758f8c3c404336e" + "reference": "9fa59908b0c5575980a1623723a5b5cb38e0a04a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/08dd97b3f22ab9ee658cd16e6758f8c3c404336e", - "reference": "08dd97b3f22ab9ee658cd16e6758f8c3c404336e", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/9fa59908b0c5575980a1623723a5b5cb38e0a04a", + "reference": "9fa59908b0c5575980a1623723a5b5cb38e0a04a", "shasum": "" }, "require": { "php": ">=5.3.9" }, - "require-dev": { - "symfony/phpunit-bridge": "~2.7" - }, "type": "library", "extra": { "branch-alias": { @@ -795,7 +800,10 @@ "autoload": { "psr-4": { "Symfony\\Component\\Stopwatch\\": "" - } + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -813,12 +821,14 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2015-09-22 13:49:29" + "time": "2015-10-30 20:10:21" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "fguillot/picodb": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/tests/units/Model/GroupMemberTest.php b/tests/units/Model/GroupMemberTest.php new file mode 100644 index 00000000..16f769e8 --- /dev/null +++ b/tests/units/Model/GroupMemberTest.php @@ -0,0 +1,76 @@ +container); + $groupMemberModel = new GroupMember($this->container); + + $this->assertEquals(1, $groupModel->create('Test')); + + $this->assertTrue($groupMemberModel->addUser(1, 1)); + $this->assertFalse($groupMemberModel->addUser(1, 1)); + + $users = $groupMemberModel->getMembers(1); + $this->assertCount(1, $users); + $this->assertEquals('admin', $users[0]['username']); + + $this->assertEmpty($groupMemberModel->getNotMembers(1)); + + $this->assertTrue($groupMemberModel->removeUser(1, 1)); + $this->assertFalse($groupMemberModel->removeUser(1, 1)); + + $this->assertEmpty($groupMemberModel->getMembers(1)); + } + + public function testMembers() + { + $userModel = new User($this->container); + $groupModel = new Group($this->container); + $groupMemberModel = new GroupMember($this->container); + + $this->assertEquals(1, $groupModel->create('Group A')); + $this->assertEquals(2, $groupModel->create('Group B')); + + $this->assertEquals(2, $userModel->create(array('username' => 'user1'))); + $this->assertEquals(3, $userModel->create(array('username' => 'user2'))); + $this->assertEquals(4, $userModel->create(array('username' => 'user3'))); + $this->assertEquals(5, $userModel->create(array('username' => 'user4'))); + + $this->assertTrue($groupMemberModel->addUser(1, 1)); + $this->assertTrue($groupMemberModel->addUser(1, 2)); + $this->assertTrue($groupMemberModel->addUser(1, 5)); + $this->assertTrue($groupMemberModel->addUser(2, 3)); + $this->assertTrue($groupMemberModel->addUser(2, 4)); + $this->assertTrue($groupMemberModel->addUser(2, 5)); + + $users = $groupMemberModel->getMembers(1); + $this->assertCount(3, $users); + $this->assertEquals('admin', $users[0]['username']); + $this->assertEquals('user1', $users[1]['username']); + $this->assertEquals('user4', $users[2]['username']); + + $users = $groupMemberModel->getNotMembers(1); + $this->assertCount(2, $users); + $this->assertEquals('user2', $users[0]['username']); + $this->assertEquals('user3', $users[1]['username']); + + $users = $groupMemberModel->getMembers(2); + $this->assertCount(3, $users); + $this->assertEquals('user2', $users[0]['username']); + $this->assertEquals('user3', $users[1]['username']); + $this->assertEquals('user4', $users[2]['username']); + + $users = $groupMemberModel->getNotMembers(2); + $this->assertCount(2, $users); + $this->assertEquals('admin', $users[0]['username']); + $this->assertEquals('user1', $users[1]['username']); + } +} diff --git a/tests/units/Model/GroupTest.php b/tests/units/Model/GroupTest.php new file mode 100644 index 00000000..2cbb254a --- /dev/null +++ b/tests/units/Model/GroupTest.php @@ -0,0 +1,82 @@ +container); + $this->assertEquals(1, $groupModel->create('Test')); + $this->assertFalse($groupModel->create('Test')); + } + + public function testGetById() + { + $groupModel = new Group($this->container); + $this->assertEquals(1, $groupModel->create('Test')); + + $group = $groupModel->getById(1); + $this->assertEquals('Test', $group['name']); + $this->assertEquals('', $group['external_id']); + + $this->assertEmpty($groupModel->getById(2)); + } + + public function testGetAll() + { + $groupModel = new Group($this->container); + $this->assertEquals(1, $groupModel->create('B')); + $this->assertEquals(2, $groupModel->create('A', 'uuid')); + + $groups = $groupModel->getAll(); + $this->assertCount(2, $groups); + $this->assertEquals('A', $groups[0]['name']); + $this->assertEquals('uuid', $groups[0]['external_id']); + $this->assertEquals('B', $groups[1]['name']); + $this->assertEquals('', $groups[1]['external_id']); + } + + public function testUpdate() + { + $groupModel = new Group($this->container); + $this->assertEquals(1, $groupModel->create('Test')); + $this->assertTrue($groupModel->update(array('id' => 1, 'name' => 'My group', 'external_id' => 'test'))); + + $group = $groupModel->getById(1); + $this->assertEquals('My group', $group['name']); + $this->assertEquals('test', $group['external_id']); + } + + public function testRemove() + { + $groupModel = new Group($this->container); + $this->assertEquals(1, $groupModel->create('Test')); + $this->assertTrue($groupModel->remove(1)); + $this->assertEmpty($groupModel->getById(1)); + } + + public function testValidateCreation() + { + $groupModel = new Group($this->container); + + $result = $groupModel->validateCreation(array('name' => 'Test')); + $this->assertTrue($result[0]); + + $result = $groupModel->validateCreation(array('name' => '')); + $this->assertFalse($result[0]); + } + + public function testValidateModification() + { + $groupModel = new Group($this->container); + + $result = $groupModel->validateModification(array('name' => 'Test', 'id' => 1)); + $this->assertTrue($result[0]); + + $result = $groupModel->validateModification(array('name' => 'Test')); + $this->assertFalse($result[0]); + } +} -- cgit v1.2.3 From f837e70a2d74eb37c4c5de7e4f54c8bf8ec78db7 Mon Sep 17 00:00:00 2001 From: Olivier Maridat Date: Thu, 26 Nov 2015 15:33:44 +0100 Subject: Add filter by task link --- app/Core/Lexer.php | 2 ++ app/Model/TaskFilter.php | 41 ++++++++++++++++++++++++ doc/search.markdown | 8 +++++ tests/units/Core/LexerTest.php | 25 +++++++++++++++ tests/units/Model/TaskFilterTest.php | 60 ++++++++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+) (limited to 'tests/units') diff --git a/app/Core/Lexer.php b/app/Core/Lexer.php index ca2ef895..df2d90ae 100644 --- a/app/Core/Lexer.php +++ b/app/Core/Lexer.php @@ -39,6 +39,7 @@ class Lexer "/^(swimlane:)/" => 'T_SWIMLANE', "/^(ref:)/" => 'T_REFERENCE', "/^(reference:)/" => 'T_REFERENCE', + "/^(link:)/" => 'T_LINK', "/^(\s+)/" => 'T_WHITESPACE', '/^([<=>]{0,2}[0-9]{4}-[0-9]{2}-[0-9]{2})/' => 'T_DATE', '/^(yesterday|tomorrow|today)/' => 'T_DATE', @@ -118,6 +119,7 @@ class Lexer case 'T_COLUMN': case 'T_PROJECT': case 'T_SWIMLANE': + case 'T_LINK': $next = next($tokens); if ($next !== false && $next['token'] === 'T_STRING') { diff --git a/app/Model/TaskFilter.php b/app/Model/TaskFilter.php index 137a7a8e..7ceb4a97 100644 --- a/app/Model/TaskFilter.php +++ b/app/Model/TaskFilter.php @@ -30,6 +30,7 @@ class TaskFilter extends Base 'T_COLUMN' => 'filterByColumnName', 'T_REFERENCE' => 'filterByReference', 'T_SWIMLANE' => 'filterBySwimlaneName', + 'T_LINK' => 'filterByLinkName', ); /** @@ -107,6 +108,22 @@ class TaskFilter extends Base ->neq(Subtask::TABLE.'.status', Subtask::STATUS_DONE); } + /** + * Create a new link query + * + * @access public + * @return \PicoDb\Table + */ + public function createLinkQuery() + { + return $this->db->table(TaskLink::TABLE) + ->columns( + TaskLink::TABLE.'.task_id', + Link::TABLE.'.label' + ) + ->join(Link::TABLE, 'id', 'link_id', TaskLink::TABLE); + } + /** * Clone the filter * @@ -506,6 +523,30 @@ class TaskFilter extends Base return $this; } + /** + * Filter by link + * + * @access public + * @param array $values List of links + * @return TaskFilter + */ + public function filterByLinkName(array $values) + { + $this->query->beginOr(); + + $link_query = $this->createLinkQuery()->in(Link::TABLE.'.label', $values); + $matching_task_ids = $link_query->findAllByColumn('task_id'); + if (empty($matching_task_ids)) { + $this->query->eq(Task::TABLE.'.id', 0); + } else { + $this->query->in(Task::TABLE.'.id', $matching_task_ids); + } + + $this->query->closeOr(); + + return $this; + } + /** * Filter by due date * diff --git a/doc/search.markdown b/doc/search.markdown index 34a20bc6..889d453f 100644 --- a/doc/search.markdown +++ b/doc/search.markdown @@ -136,3 +136,11 @@ Attribute: **swimlane** - Find tasks in the default swimlane: `swimlane:default` - Find tasks into several swimlanes: `swimlane:"Version 1.2" swimlane:"Version 1.3"` +Search by task link +------------------ + +Attribute: **link** + +- Find tasks by link name: `link:"is a milestone of"` +- Find tasks into several links: `link:"is a milestone of" link:"relates to"` + diff --git a/tests/units/Core/LexerTest.php b/tests/units/Core/LexerTest.php index 9e14ff6b..55370aab 100644 --- a/tests/units/Core/LexerTest.php +++ b/tests/units/Core/LexerTest.php @@ -116,6 +116,31 @@ class LexerTest extends Base ); } + public function testLinkQuery() + { + $lexer = new Lexer; + + $this->assertEquals( + array(array('match' => 'link:', 'token' => 'T_LINK'), array('match' => 'is a milestone of', 'token' => 'T_STRING')), + $lexer->tokenize('link:"is a milestone of"') + ); + + $this->assertEquals( + array('T_LINK' => array('is a milestone of')), + $lexer->map($lexer->tokenize('link:"is a milestone of"')) + ); + + $this->assertEquals( + array('T_LINK' => array('is a milestone of', 'fixes')), + $lexer->map($lexer->tokenize('link:"is a milestone of" link:fixes')) + ); + + $this->assertEquals( + array(), + $lexer->map($lexer->tokenize('link: ')) + ); + } + public function testColumnQuery() { $lexer = new Lexer; diff --git a/tests/units/Model/TaskFilterTest.php b/tests/units/Model/TaskFilterTest.php index b668b7cc..daa193b2 100644 --- a/tests/units/Model/TaskFilterTest.php +++ b/tests/units/Model/TaskFilterTest.php @@ -6,6 +6,7 @@ use Kanboard\Model\Project; use Kanboard\Model\User; use Kanboard\Model\TaskFilter; use Kanboard\Model\TaskCreation; +use Kanboard\Model\TaskLink; use Kanboard\Core\DateParser; use Kanboard\Model\Category; use Kanboard\Model\Subtask; @@ -552,6 +553,65 @@ class TaskFilterTest extends Base $this->assertEquals('task3', $tasks[0]['title']); } + public function testSearchWithLink() + { + $p = new Project($this->container); + $u = new User($this->container); + $tc = new TaskCreation($this->container); + $tl = new TaskLink($this->container); + $tf = new TaskFilter($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'test'))); + $this->assertEquals(2, $u->create(array('username' => 'bob', 'name' => 'Bob Ryan'))); + $this->assertNotFalse($tc->create(array('project_id' => 1, 'title' => 'my task title is awesome', 'color_id' => 'light_green'))); + $this->assertNotFalse($tc->create(array('project_id' => 1, 'title' => 'my task title is amazing', 'color_id' => 'blue'))); + $this->assertNotFalse($tc->create(array('project_id' => 1, 'title' => 'Bob at work'))); + $this->assertNotFalse($tc->create(array('project_id' => 1, 'title' => 'I have a bad feeling about that'))); + $this->assertEquals(1, $tl->create(1, 2, 9)); // #1 is a milestone of #2 + $this->assertEquals(3, $tl->create(2, 1, 2)); // #2 blocks #1 + $this->assertEquals(5, $tl->create(3, 2, 2)); // #3 blocks #2 + + $tf->search('link:"is a milestone of"'); + $tasks = $tf->findAll(); + $this->assertNotEmpty($tasks); + $this->assertCount(1, $tasks); + $this->assertEquals('my task title is awesome', $tasks[0]['title']); + + $tf->search('link:"is a milestone of" amazing'); + $tasks = $tf->findAll(); + $this->assertEmpty($tasks); + + $tf->search('link:"unknown"'); + $tasks = $tf->findAll(); + $this->assertEmpty($tasks); + + $tf->search('link:unknown'); + $tasks = $tf->findAll(); + $this->assertEmpty($tasks); + + $tf->search('link:blocks amazing'); + $tasks = $tf->findAll(); + $this->assertNotEmpty($tasks); + $this->assertCount(1, $tasks); + $this->assertEquals('my task title is amazing', $tasks[0]['title']); + + $tf->search('link:"is a milestone of" link:blocks'); + $tasks = $tf->findAll(); + $this->assertNotEmpty($tasks); + $this->assertCount(3, $tasks); + $this->assertEquals('my task title is awesome', $tasks[0]['title']); + $this->assertEquals('my task title is amazing', $tasks[1]['title']); + $this->assertEquals('Bob at work', $tasks[2]['title']); + + $tf->search('link:"is a milestone of" link:blocks link:unknown'); + $tasks = $tf->findAll(); + $this->assertNotEmpty($tasks); + $this->assertCount(3, $tasks); + $this->assertEquals('my task title is awesome', $tasks[0]['title']); + $this->assertEquals('my task title is amazing', $tasks[1]['title']); + $this->assertEquals('Bob at work', $tasks[2]['title']); + } + public function testCopy() { $tf = new TaskFilter($this->container); -- cgit v1.2.3 From 2451706316f08db2aa4b79730c02d297a5f5d59b Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Fri, 27 Nov 2015 09:15:12 -0500 Subject: Add generic LDAP client library --- ChangeLog | 1 + app/Core/Ldap/Client.php | 84 +++++++++++++++ app/Core/Ldap/ClientException.php | 15 +++ app/Core/Ldap/Query.php | 95 +++++++++++++++++ app/Core/Ldap/User.php | 178 ++++++++++++++++++++++++++++++++ config.default.php | 13 --- tests/units/Core/Ldap/ClientTest.php | 195 +++++++++++++++++++++++++++++++++++ tests/units/Core/Ldap/QueryTest.php | 137 ++++++++++++++++++++++++ tests/units/Core/Ldap/UserTest.php | 95 +++++++++++++++++ 9 files changed, 800 insertions(+), 13 deletions(-) create mode 100644 app/Core/Ldap/Client.php create mode 100644 app/Core/Ldap/ClientException.php create mode 100644 app/Core/Ldap/Query.php create mode 100644 app/Core/Ldap/User.php create mode 100644 tests/units/Core/Ldap/ClientTest.php create mode 100644 tests/units/Core/Ldap/QueryTest.php create mode 100644 tests/units/Core/Ldap/UserTest.php (limited to 'tests/units') diff --git a/ChangeLog b/ChangeLog index ed94807d..5a858885 100644 --- a/ChangeLog +++ b/ChangeLog @@ -4,6 +4,7 @@ Version 1.0.22 (unreleased) New features: * User groups (Teams) +* Add generic LDAP client library * Pluggable authentication and authorization system (Work in progress) * Add new project role Viewer (Work in progress) * Assign project permissions to a group (Work in progress) diff --git a/app/Core/Ldap/Client.php b/app/Core/Ldap/Client.php new file mode 100644 index 00000000..a523428c --- /dev/null +++ b/app/Core/Ldap/Client.php @@ -0,0 +1,84 @@ +entries = $entries; + } + + /** + * Execute query + * + * @access public + * @param resource $ldap + * @param string $baseDn + * @param string $filter + * @param array $attributes + * @return Query + */ + public function execute($ldap, $baseDn, $filter, array $attributes) + { + $sr = ldap_search($ldap, $baseDn, $filter, $attributes); + if ($sr === false) { + return $this; + } + + $entries = ldap_get_entries($ldap, $sr); + if ($entries === false || count($entries) === 0 || $entries['count'] == 0) { + return $this; + } + + $this->entries = $entries; + + return $this; + } + + /** + * Return true if the query returned a result + * + * @access public + * @return boolean + */ + public function hasResult() + { + return ! empty($this->entries); + } + + /** + * Return subset of entries + * + * @access public + * @param string $key + * @param mixed $default + * @return array + */ + public function getAttribute($key, $default = null) + { + return isset($this->entries[0][$key]) ? $this->entries[0][$key] : $default; + } + + /** + * Return one entry from a list of entries + * + * @access public + * @param string $key Key + * @param string $default Default value if key not set in entry + * @return string + */ + public function getAttributeValue($key, $default = '') + { + return isset($this->entries[0][$key][0]) ? $this->entries[0][$key][0] : $default; + } +} diff --git a/app/Core/Ldap/User.php b/app/Core/Ldap/User.php new file mode 100644 index 00000000..e44a4dda --- /dev/null +++ b/app/Core/Ldap/User.php @@ -0,0 +1,178 @@ +query = $query ?: new Query; + } + + /** + * Get user profile + * + * @access public + * @param resource $ldap + * @param string $baseDn + * @param string $query + * @return array + */ + public function getProfile($ldap, $baseDn, $query) + { + $this->query->execute($ldap, $baseDn, $query, $this->getAttributes()); + $profile = array(); + + if ($this->query->hasResult()) { + $profile = $this->prepareProfile(); + } + + return $profile; + } + + /** + * Build user profile + * + * @access private + * @return boolean|array + */ + private function prepareProfile() + { + return array( + 'ldap_id' => $this->query->getAttribute('dn', ''), + 'username' => $this->query->getAttributeValue($this->getAttributeUsername()), + 'name' => $this->query->getAttributeValue($this->getAttributeName()), + 'email' => $this->query->getAttributeValue($this->getAttributeEmail()), + 'is_admin' => (int) $this->isMemberOf($this->query->getAttribute($this->getAttributeGroup(), array()), $this->getGroupAdminDn()), + 'is_project_admin' => (int) $this->isMemberOf($this->query->getAttribute($this->getAttributeGroup(), array()), $this->getGroupProjectAdminDn()), + 'is_ldap_user' => 1, + ); + } + + /** + * Check group membership + * + * @access public + * @param array $group_entries + * @param string $group_dn + * @return boolean + */ + public function isMemberOf(array $group_entries, $group_dn) + { + if (! isset($group_entries['count']) || empty($group_dn)) { + return false; + } + + for ($i = 0; $i < $group_entries['count']; $i++) { + if ($group_entries[$i] === $group_dn) { + return true; + } + } + + return false; + } + + /** + * Ge the list of attributes to fetch when reading the LDAP user entry + * + * Must returns array with index that start at 0 otherwise ldap_search returns a warning "Array initialization wrong" + * + * @access public + * @return array + */ + public function getAttributes() + { + return array_values(array_filter(array( + $this->getAttributeUsername(), + $this->getAttributeName(), + $this->getAttributeEmail(), + $this->getAttributeGroup(), + ))); + } + + /** + * Get LDAP account id attribute + * + * @access public + * @return string + */ + public function getAttributeUsername() + { + return LDAP_ACCOUNT_ID; + } + + /** + * Get LDAP account email attribute + * + * @access public + * @return string + */ + public function getAttributeEmail() + { + return LDAP_ACCOUNT_EMAIL; + } + + /** + * Get LDAP account name attribute + * + * @access public + * @return string + */ + public function getAttributeName() + { + return LDAP_ACCOUNT_FULLNAME; + } + + /** + * Get LDAP account memberof attribute + * + * @access public + * @return string + */ + public function getAttributeGroup() + { + return LDAP_ACCOUNT_MEMBEROF; + } + + /** + * Get LDAP admin group DN + * + * @access public + * @return string + */ + public function getGroupAdminDn() + { + return LDAP_GROUP_ADMIN_DN; + } + + /** + * Get LDAP project admin group DN + * + * @access public + * @return string + */ + public function getGroupProjectAdminDn() + { + return LDAP_GROUP_PROJECT_ADMIN_DN; + } +} diff --git a/config.default.php b/config.default.php index 91bb8d17..067d9d60 100644 --- a/config.default.php +++ b/config.default.php @@ -32,19 +32,6 @@ define('MAIL_SMTP_ENCRYPTION', null); // Valid values are "null", "ssl" or "tls" // Sendmail command to use when the transport is "sendmail" define('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'); -// Postmark API token (used to send emails through their API) -define('POSTMARK_API_TOKEN', ''); - -// Mailgun API key (used to send emails through their API) -define('MAILGUN_API_TOKEN', ''); - -// Mailgun domain name -define('MAILGUN_DOMAIN', ''); - -// Sendgrid API configuration -define('SENDGRID_API_USER', ''); -define('SENDGRID_API_KEY', ''); - // Database driver: sqlite, mysql or postgres (sqlite by default) define('DB_DRIVER', 'sqlite'); diff --git a/tests/units/Core/Ldap/ClientTest.php b/tests/units/Core/Ldap/ClientTest.php new file mode 100644 index 00000000..7b6e983d --- /dev/null +++ b/tests/units/Core/Ldap/ClientTest.php @@ -0,0 +1,195 @@ +ldap_connect($hostname, $port); +} + +function ldap_set_option() +{ +} + +function ldap_bind($link_identifier, $bind_rdn = null, $bind_password = null) +{ + return ClientTest::$functions->ldap_bind($link_identifier, $bind_rdn, $bind_password); +} + +function ldap_start_tls($link_identifier) +{ + return ClientTest::$functions->ldap_start_tls($link_identifier); +} + +class ClientTest extends \Base +{ + public static $functions; + private $ldap; + + public function setUp() + { + parent::setup(); + + self::$functions = $this + ->getMockBuilder('stdClass') + ->setMethods(array( + 'ldap_connect', + 'ldap_set_option', + 'ldap_bind', + 'ldap_start_tls', + )) + ->getMock(); + } + + public function tearDown() + { + parent::tearDown(); + self::$functions = null; + } + + public function testConnectSuccess() + { + self::$functions + ->expects($this->once()) + ->method('ldap_connect') + ->with( + $this->equalTo('my_ldap_server'), + $this->equalTo(389) + ) + ->will($this->returnValue('my_ldap_resource')); + + $ldap = new Client; + $this->assertEquals('my_ldap_resource', $ldap->getConnection('my_ldap_server')); + } + + public function testConnectFailure() + { + self::$functions + ->expects($this->once()) + ->method('ldap_connect') + ->with( + $this->equalTo('my_ldap_server'), + $this->equalTo(389) + ) + ->will($this->returnValue(false)); + + $this->setExpectedException('\Kanboard\Core\Ldap\ClientException'); + + $ldap = new Client; + $this->assertNotEquals('my_ldap_resource', $ldap->getConnection('my_ldap_server')); + } + + public function testConnectSuccessWithTLS() + { + self::$functions + ->expects($this->once()) + ->method('ldap_connect') + ->with( + $this->equalTo('my_ldap_server'), + $this->equalTo(389) + ) + ->will($this->returnValue('my_ldap_resource')); + + self::$functions + ->expects($this->once()) + ->method('ldap_start_tls') + ->with( + $this->equalTo('my_ldap_resource') + ) + ->will($this->returnValue(true)); + + $ldap = new Client; + $this->assertEquals('my_ldap_resource', $ldap->getConnection('my_ldap_server', 389, true)); + } + + public function testConnectFailureWithTLS() + { + self::$functions + ->expects($this->once()) + ->method('ldap_connect') + ->with( + $this->equalTo('my_ldap_server'), + $this->equalTo(389) + ) + ->will($this->returnValue('my_ldap_resource')); + + self::$functions + ->expects($this->once()) + ->method('ldap_start_tls') + ->with( + $this->equalTo('my_ldap_resource') + ) + ->will($this->returnValue(false)); + + $this->setExpectedException('\Kanboard\Core\Ldap\ClientException'); + + $ldap = new Client; + $this->assertNotEquals('my_ldap_resource', $ldap->getConnection('my_ldap_server', 389, true)); + } + + public function testAnonymousAuthenticationSuccess() + { + self::$functions + ->expects($this->once()) + ->method('ldap_bind') + ->with( + $this->equalTo('my_ldap_resource') + ) + ->will($this->returnValue(true)); + + $ldap = new Client; + $this->assertTrue($ldap->useAnonymousAuthentication('my_ldap_resource')); + } + + public function testAnonymousAuthenticationFailure() + { + self::$functions + ->expects($this->once()) + ->method('ldap_bind') + ->with( + $this->equalTo('my_ldap_resource') + ) + ->will($this->returnValue(false)); + + $this->setExpectedException('\Kanboard\Core\Ldap\ClientException'); + + $ldap = new Client; + $ldap->useAnonymousAuthentication('my_ldap_resource'); + } + + public function testUserAuthenticationSuccess() + { + self::$functions + ->expects($this->once()) + ->method('ldap_bind') + ->with( + $this->equalTo('my_ldap_resource'), + $this->equalTo('my_ldap_user'), + $this->equalTo('my_ldap_password') + ) + ->will($this->returnValue(true)); + + $ldap = new Client; + $this->assertTrue($ldap->authenticate('my_ldap_resource', 'my_ldap_user', 'my_ldap_password')); + } + + public function testUserAuthenticationFailure() + { + self::$functions + ->expects($this->once()) + ->method('ldap_bind') + ->with( + $this->equalTo('my_ldap_resource'), + $this->equalTo('my_ldap_user'), + $this->equalTo('my_ldap_password') + ) + ->will($this->returnValue(false)); + + $this->setExpectedException('\Kanboard\Core\Ldap\ClientException'); + + $ldap = new Client; + $ldap->authenticate('my_ldap_resource', 'my_ldap_user', 'my_ldap_password'); + } +} diff --git a/tests/units/Core/Ldap/QueryTest.php b/tests/units/Core/Ldap/QueryTest.php new file mode 100644 index 00000000..2eb3940f --- /dev/null +++ b/tests/units/Core/Ldap/QueryTest.php @@ -0,0 +1,137 @@ +ldap_search($link_identifier, $base_dn, $filter, $attributes); +} + +function ldap_get_entries($link_identifier, $result_identifier) +{ + return QueryTest::$functions->ldap_get_entries($link_identifier, $result_identifier); +} + +class QueryTest extends \Base +{ + public static $functions; + + public function setUp() + { + parent::setup(); + + self::$functions = $this + ->getMockBuilder('stdClass') + ->setMethods(array( + 'ldap_search', + 'ldap_get_entries', + )) + ->getMock(); + } + + public function tearDown() + { + parent::tearDown(); + self::$functions = null; + } + + public function testExecuteQuerySuccessfully() + { + $entries = array( + 'count' => 1, + 0 => array( + 'count' => 2, + 'dn' => 'uid=my_user,ou=People,dc=kanboard,dc=local', + 'displayname' => array( + 'count' => 1, + 0 => 'My user', + ), + 'mail' => array( + 'count' => 2, + 0 => 'user1@localhost', + 1 => 'user2@localhost', + ), + 0 => 'displayname', + 1 => 'mail', + ) + ); + + self::$functions + ->expects($this->once()) + ->method('ldap_search') + ->with( + $this->equalTo('my_ldap_resource'), + $this->equalTo('ou=People,dc=kanboard,dc=local'), + $this->equalTo('uid=my_user'), + $this->equalTo(array('displayname')) + ) + ->will($this->returnValue('search_resource')); + + self::$functions + ->expects($this->once()) + ->method('ldap_get_entries') + ->with( + $this->equalTo('my_ldap_resource'), + $this->equalTo('search_resource') + ) + ->will($this->returnValue($entries)); + + $query = new Query; + $query->execute('my_ldap_resource', 'ou=People,dc=kanboard,dc=local', 'uid=my_user', array('displayname')); + $this->assertTrue($query->hasResult()); + + $this->assertEquals('My user', $query->getAttributeValue('displayname')); + $this->assertEquals('user1@localhost', $query->getAttributeValue('mail')); + $this->assertEquals('', $query->getAttributeValue('not_found')); + + $this->assertEquals('uid=my_user,ou=People,dc=kanboard,dc=local', $query->getAttribute('dn')); + $this->assertEquals(null, $query->getAttribute('missing')); + } + + public function testExecuteQueryNotFound() + { + self::$functions + ->expects($this->once()) + ->method('ldap_search') + ->with( + $this->equalTo('my_ldap_resource'), + $this->equalTo('ou=People,dc=kanboard,dc=local'), + $this->equalTo('uid=my_user'), + $this->equalTo(array('displayname')) + ) + ->will($this->returnValue('search_resource')); + + self::$functions + ->expects($this->once()) + ->method('ldap_get_entries') + ->with( + $this->equalTo('my_ldap_resource'), + $this->equalTo('search_resource') + ) + ->will($this->returnValue(array())); + + $query = new Query; + $query->execute('my_ldap_resource', 'ou=People,dc=kanboard,dc=local', 'uid=my_user', array('displayname')); + $this->assertFalse($query->hasResult()); + } + + public function testExecuteQueryFailed() + { + self::$functions + ->expects($this->once()) + ->method('ldap_search') + ->with( + $this->equalTo('my_ldap_resource'), + $this->equalTo('ou=People,dc=kanboard,dc=local'), + $this->equalTo('uid=my_user'), + $this->equalTo(array('displayname')) + ) + ->will($this->returnValue(false)); + + $query = new Query; + $query->execute('my_ldap_resource', 'ou=People,dc=kanboard,dc=local', 'uid=my_user', array('displayname')); + $this->assertFalse($query->hasResult()); + } +} diff --git a/tests/units/Core/Ldap/UserTest.php b/tests/units/Core/Ldap/UserTest.php new file mode 100644 index 00000000..b19592c2 --- /dev/null +++ b/tests/units/Core/Ldap/UserTest.php @@ -0,0 +1,95 @@ + 1, + 0 => array( + 'count' => 2, + 'dn' => 'uid=my_user,ou=People,dc=kanboard,dc=local', + 'displayname' => array( + 'count' => 1, + 0 => 'My LDAP user', + ), + 'mail' => array( + 'count' => 2, + 0 => 'user1@localhost', + 1 => 'user2@localhost', + ), + 'samaccountname' => array( + 'count' => 1, + 0 => 'my_ldap_user', + ), + 0 => 'displayname', + 1 => 'mail', + 2 => 'samaccountname', + ) + ); + + $expected = array( + 'ldap_id' => 'uid=my_user,ou=People,dc=kanboard,dc=local', + 'username' => 'my_ldap_user', + 'name' => 'My LDAP user', + 'email' => 'user1@localhost', + 'is_admin' => 0, + 'is_project_admin' => 0, + 'is_ldap_user' => 1, + ); + + $query = $this + ->getMockBuilder('\Kanboard\Core\Ldap\Query') + ->setConstructorArgs(array($entries)) + ->setMethods(array( + 'execute', + 'hasResult', + )) + ->getMock(); + + $query + ->expects($this->once()) + ->method('execute') + ->with( + $this->equalTo('my_ldap_resource'), + $this->equalTo('ou=People,dc=kanboard,dc=local'), + $this->equalTo('(uid=my_user)') + ); + + $query + ->expects($this->once()) + ->method('hasResult') + ->will($this->returnValue(true)); + + $user = $this + ->getMockBuilder('\Kanboard\Core\Ldap\User') + ->setConstructorArgs(array($query)) + ->setMethods(array( + 'getAttributeUsername', + 'getAttributeEmail', + 'getAttributeName', + )) + ->getMock(); + + $user + ->expects($this->any()) + ->method('getAttributeUsername') + ->will($this->returnValue('samaccountname')); + + $user + ->expects($this->any()) + ->method('getAttributeName') + ->will($this->returnValue('displayname')); + + $user + ->expects($this->any()) + ->method('getAttributeEmail') + ->will($this->returnValue('mail')); + + $this->assertEquals($expected, $user->getProfile('my_ldap_resource', 'ou=People,dc=kanboard,dc=local', '(uid=my_user)')); + } +} -- cgit v1.2.3 From 19706944dc94c4fe1784af434f5f2e27a3c8130c Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Fri, 27 Nov 2015 11:01:39 -0500 Subject: Fix broken test --- tests/units/Core/Ldap/UserTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'tests/units') diff --git a/tests/units/Core/Ldap/UserTest.php b/tests/units/Core/Ldap/UserTest.php index b19592c2..56cc588c 100644 --- a/tests/units/Core/Ldap/UserTest.php +++ b/tests/units/Core/Ldap/UserTest.php @@ -1,10 +1,10 @@ Date: Fri, 27 Nov 2015 16:24:21 -0500 Subject: Add generic authorization class --- app/Auth/Database.php | 49 ------------- app/Core/Security/AccessMap.php | 92 +++++++++++++++++++++++++ app/Core/Security/Authorization.php | 46 +++++++++++++ app/Core/Security/Role.php | 21 ++++++ tests/units/Core/Security/AccessMapTest.php | 22 ++++++ tests/units/Core/Security/AuthorizationTest.php | 28 ++++++++ 6 files changed, 209 insertions(+), 49 deletions(-) delete mode 100644 app/Auth/Database.php create mode 100644 app/Core/Security/AccessMap.php create mode 100644 app/Core/Security/Authorization.php create mode 100644 app/Core/Security/Role.php create mode 100644 tests/units/Core/Security/AccessMapTest.php create mode 100644 tests/units/Core/Security/AuthorizationTest.php (limited to 'tests/units') diff --git a/app/Auth/Database.php b/app/Auth/Database.php deleted file mode 100644 index c2041d4d..00000000 --- a/app/Auth/Database.php +++ /dev/null @@ -1,49 +0,0 @@ -db - ->table(User::TABLE) - ->eq('username', $username) - ->eq('disable_login_form', 0) - ->eq('is_ldap_user', 0) - ->findOne(); - - if (is_array($user) && password_verify($password, $user['password'])) { - $this->userSession->initialize($user); - $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); - return true; - } - - return false; - } -} diff --git a/app/Core/Security/AccessMap.php b/app/Core/Security/AccessMap.php new file mode 100644 index 00000000..10a29e1f --- /dev/null +++ b/app/Core/Security/AccessMap.php @@ -0,0 +1,92 @@ +defaultRole = $role; + return $this; + } + + /** + * Add new access rules + * + * @access public + * @param string $controller + * @param string $method + * @param array $roles + * @return Acl + */ + public function add($controller, $method, array $roles) + { + $controller = strtolower($controller); + $method = strtolower($method); + + if (! isset($this->map[$controller])) { + $this->map[$controller] = array(); + } + + if (! isset($this->map[$controller][$method])) { + $this->map[$controller][$method] = array(); + } + + $this->map[$controller][$method] = $roles; + + return $this; + } + + /** + * Get roles that match the given controller/method + * + * @access public + * @param string $controller + * @param string $method + * @return boolean + */ + public function getRoles($controller, $method) + { + $controller = strtolower($controller); + $method = strtolower($method); + + if (isset($this->map[$controller][$method])) { + return $this->map[$controller][$method]; + } + + if (isset($this->map[$controller]['*'])) { + return $this->map[$controller]['*']; + } + + return array($this->defaultRole); + } +} diff --git a/app/Core/Security/Authorization.php b/app/Core/Security/Authorization.php new file mode 100644 index 00000000..a04b3720 --- /dev/null +++ b/app/Core/Security/Authorization.php @@ -0,0 +1,46 @@ +acl = $acl; + } + + /** + * Check if the given role is allowed to access to the specified resource + * + * @access public + * @param string $controller + * @param string $method + * @param string $role + * @return boolean + */ + public function isAllowed($controller, $method, $role) + { + $roles = $this->acl->getRoles($controller, $method); + return in_array($role, $roles); + } +} diff --git a/app/Core/Security/Role.php b/app/Core/Security/Role.php new file mode 100644 index 00000000..079ce14b --- /dev/null +++ b/app/Core/Security/Role.php @@ -0,0 +1,21 @@ +setDefaultRole('role3'); + $acl->add('MyController', 'myAction1', array('role1', 'role2')); + $acl->add('MyController', 'myAction2', array('role1')); + $acl->add('MyAdminController', '*', array('role2')); + + $this->assertEquals(array('role1', 'role2'), $acl->getRoles('mycontroller', 'MyAction1')); + $this->assertEquals(array('role1'), $acl->getRoles('mycontroller', 'MyAction2')); + $this->assertEquals(array('role2'), $acl->getRoles('Myadmincontroller', 'MyAction')); + $this->assertEquals(array('role3'), $acl->getRoles('AnotherController', 'ActionNotFound')); + } +} diff --git a/tests/units/Core/Security/AuthorizationTest.php b/tests/units/Core/Security/AuthorizationTest.php new file mode 100644 index 00000000..ffeb3741 --- /dev/null +++ b/tests/units/Core/Security/AuthorizationTest.php @@ -0,0 +1,28 @@ +setDefaultRole(Role::APP_USER); + $acl->add('MyController', 'myAction1', array(Role::APP_ADMIN, Role::APP_MANAGER)); + $acl->add('MyController', 'myAction2', array(Role::APP_ADMIN)); + $acl->add('MyAdminController', '*', array(Role::APP_MANAGER)); + + $authorization = new Authorization($acl); + $this->assertTrue($authorization->isAllowed('myController', 'myAction1', Role::APP_ADMIN)); + $this->assertTrue($authorization->isAllowed('myController', 'myAction1', Role::APP_MANAGER)); + $this->assertFalse($authorization->isAllowed('myController', 'myAction1', Role::APP_USER)); + $this->assertTrue($authorization->isAllowed('anotherController', 'anotherAction', Role::APP_USER)); + $this->assertTrue($authorization->isAllowed('MyAdminController', 'myAction', Role::APP_MANAGER)); + $this->assertFalse($authorization->isAllowed('MyAdminController', 'myAction', Role::APP_ADMIN)); + $this->assertFalse($authorization->isAllowed('MyAdminController', 'myAction', 'something else')); + } +} -- cgit v1.2.3 From 207ee05b0ef2d084489d5b01c071b68a4f5075ca Mon Sep 17 00:00:00 2001 From: Matthew Cillo Date: Thu, 3 Dec 2015 22:54:27 -0500 Subject: added tests --- tests/units/Integration/GitlabWebhookTest.php | 36 +++++++++++++++++++++++++ tests/units/fixtures/gitlab_issue_reopened.json | 25 +++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/units/fixtures/gitlab_issue_reopened.json (limited to 'tests/units') diff --git a/tests/units/Integration/GitlabWebhookTest.php b/tests/units/Integration/GitlabWebhookTest.php index 6d37819b..fa05f292 100644 --- a/tests/units/Integration/GitlabWebhookTest.php +++ b/tests/units/Integration/GitlabWebhookTest.php @@ -64,6 +64,35 @@ class GitlabWebhookTest extends Base $this->assertArrayHasKey(GitlabWebhook::EVENT_ISSUE_OPENED.'.GitlabWebhookTest::onOpen', $called); } + public function testHandleIssueReopened() + { + $g = new GitlabWebhook($this->container); + $p = new Project($this->container); + $tc = new TaskCreation($this->container); + $tf = new TaskFinder($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'test'))); + $g->setProjectId(1); + + $this->container['dispatcher']->addListener(GitlabWebhook::EVENT_ISSUE_REOPENED, array($this, 'onReopen')); + + $event = json_decode(file_get_contents(__DIR__.'/../fixtures/gitlab_issue_reopened.json'), true); + + // Issue not there + $this->assertFalse($g->handleIssueReopened($event['object_attributes'])); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertEmpty($called); + + $this->assertEquals(1, $tc->create(array('title' => 'A', 'project_id' => 1, 'reference' => 355691))); + $task = $tf->getByReference(1, 355691); + $this->assertTrue($g->handleIssueReopened($event['object_attributes'])); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(GitlabWebhook::EVENT_ISSUE_REOPENED.'.GitlabWebhookTest::onReopen', $called); + } + + public function testHandleIssueClosed() { $g = new GitlabWebhook($this->container); @@ -170,6 +199,13 @@ class GitlabWebhookTest extends Base $this->assertEquals("There is a bug somewhere.\r\n\r\nBye\n\n[Gitlab Issue](https://gitlab.com/minicoders/test-webhook/issues/1)", $data['description']); } + public function onReopen($event) + { + $data = $event->getAll(); + $this->assertEquals(1, $data['project_id']); + $this->assertEquals(1, $data['task_id']); + $this->assertEquals(355691, $data['reference']); + } public function onClose($event) { $data = $event->getAll(); diff --git a/tests/units/fixtures/gitlab_issue_reopened.json b/tests/units/fixtures/gitlab_issue_reopened.json new file mode 100644 index 00000000..bf76262d --- /dev/null +++ b/tests/units/fixtures/gitlab_issue_reopened.json @@ -0,0 +1,25 @@ +{ + "object_kind": "issue", + "user": { + "name": "Fred", + "username": "minicoders", + "avatar_url": "https://secure.gravatar.com/avatar/3c44936e5a56f80711bff14987d2733f?s=40&d=identicon" + }, + "object_attributes": { + "id": 355691, + "title": "Bug", + "assignee_id": null, + "author_id": 74067, + "project_id": 320820, + "created_at": "2015-07-17 21:31:47 UTC", + "updated_at": "2015-07-17 21:31:47 UTC", + "position": 0, + "branch_name": null, + "description": "There is a bug somewhere.\r\n\r\nBye", + "milestone_id": null, + "state": "opened", + "iid": 1, + "url": "https://gitlab.com/minicoders/test-webhook/issues/1", + "action": "reopen" + } +} -- cgit v1.2.3 From e9fedf3e5cd63aea4da7a71f6647ee427c62fa49 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 5 Dec 2015 20:31:27 -0500 Subject: Rewrite of the authentication and authorization system --- ChangeLog | 26 +- Makefile | 2 +- app/Api/Auth.php | 35 +- app/Api/Me.php | 8 +- app/Api/ProjectPermission.php | 8 +- app/Api/User.php | 55 +- app/Auth/DatabaseAuth.php | 125 + app/Auth/Github.php | 123 - app/Auth/GithubAuth.php | 143 + app/Auth/Gitlab.php | 123 - app/Auth/GitlabAuth.php | 143 + app/Auth/Google.php | 124 - app/Auth/GoogleAuth.php | 143 + app/Auth/Ldap.php | 521 --- app/Auth/LdapAuth.php | 187 + app/Auth/RememberMe.php | 323 -- app/Auth/RememberMeAuth.php | 79 + app/Auth/ReverseProxy.php | 83 - app/Auth/ReverseProxyAuth.php | 76 + app/Auth/TotpAuth.php | 126 + app/Controller/Action.php | 4 +- app/Controller/Activity.php | 2 +- app/Controller/Analytic.php | 5 +- app/Controller/App.php | 51 +- app/Controller/Auth.php | 30 +- app/Controller/Base.php | 113 +- app/Controller/Board.php | 193 +- app/Controller/BoardPopover.php | 101 + app/Controller/BoardTooltip.php | 112 + app/Controller/Config.php | 2 +- app/Controller/Currency.php | 2 +- app/Controller/Customfilter.php | 2 +- app/Controller/Doc.php | 2 +- app/Controller/Feed.php | 4 +- app/Controller/Gantt.php | 8 +- app/Controller/Group.php | 10 +- app/Controller/GroupHelper.php | 24 + app/Controller/Link.php | 2 +- app/Controller/Oauth.php | 43 +- app/Controller/Project.php | 139 +- app/Controller/ProjectPermission.php | 177 + app/Controller/Projectuser.php | 13 +- app/Controller/Search.php | 2 +- app/Controller/Subtask.php | 4 +- app/Controller/Task.php | 2 +- app/Controller/TaskHelper.php | 57 + app/Controller/Taskcreation.php | 2 +- app/Controller/Taskduplication.php | 6 +- app/Controller/Taskmodification.php | 2 +- app/Controller/Twofactor.php | 33 +- app/Controller/User.php | 35 +- app/Controller/UserHelper.php | 24 + app/Core/Base.php | 50 +- app/Core/Cache/MemoryCache.php | 2 +- app/Core/Group/GroupBackendProviderInterface.php | 21 + app/Core/Group/GroupManager.php | 71 + app/Core/Group/GroupProviderInterface.php | 40 + app/Core/Http/OAuth2.php | 121 + app/Core/Http/RememberMeCookie.php | 120 + app/Core/Http/Request.php | 125 +- app/Core/Http/Response.php | 2 +- app/Core/Ldap/Client.php | 119 +- app/Core/Ldap/Entries.php | 63 + app/Core/Ldap/Entry.php | 91 + app/Core/Ldap/Group.php | 130 + app/Core/Ldap/Query.php | 44 +- app/Core/Ldap/User.php | 135 +- app/Core/OAuth2.php | 119 - app/Core/Security/AccessMap.php | 91 +- app/Core/Security/AuthenticationManager.php | 187 + .../Security/AuthenticationProviderInterface.php | 28 + app/Core/Security/Authorization.php | 10 +- .../OAuthAuthenticationProviderInterface.php | 46 + .../PasswordAuthenticationProviderInterface.php | 36 + .../PostAuthenticationProviderInterface.php | 54 + .../PreAuthenticationProviderInterface.php | 20 + app/Core/Security/Role.php | 43 + .../Security/SessionCheckProviderInterface.php | 20 + app/Core/Session/SessionManager.php | 13 +- app/Core/Session/SessionStorage.php | 3 +- app/Core/User/GroupSync.php | 32 + app/Core/User/UserProfile.php | 62 + app/Core/User/UserProperty.php | 70 + app/Core/User/UserProviderInterface.php | 103 + app/Core/User/UserSession.php | 204 + app/Core/User/UserSync.php | 76 + app/Event/AuthEvent.php | 27 - app/Event/AuthFailureEvent.php | 44 + app/Event/AuthSuccessEvent.php | 43 + app/Formatter/GroupAutoCompleteFormatter.php | 55 + app/Formatter/ProjectGanttFormatter.php | 2 +- app/Formatter/UserFilterAutoCompleteFormatter.php | 38 + app/Group/DatabaseBackendGroupProvider.php | 34 + app/Group/DatabaseGroupProvider.php | 66 + app/Group/LdapBackendGroupProvider.php | 54 + app/Group/LdapGroupProvider.php | 76 + app/Helper/Url.php | 2 +- app/Helper/User.php | 68 +- app/Model/Acl.php | 289 -- app/Model/Authentication.php | 194 +- app/Model/Group.php | 24 + app/Model/GroupMember.php | 24 +- app/Model/Project.php | 5 +- app/Model/ProjectAnalytic.php | 2 +- app/Model/ProjectGroupRole.php | 187 + app/Model/ProjectPermission.php | 447 +- app/Model/ProjectUserRole.php | 263 ++ app/Model/RememberMeSession.php | 151 + app/Model/TaskPermission.php | 4 +- app/Model/User.php | 129 +- app/Model/UserFilter.php | 80 + app/Model/UserImport.php | 18 +- app/Model/UserLocking.php | 103 + app/Model/UserNotification.php | 2 +- app/Model/UserSession.php | 195 - app/Schema/Mysql.php | 61 +- app/Schema/Postgres.php | 61 +- app/Schema/Sqlite.php | 42 +- app/ServiceProvider/AuthenticationProvider.php | 149 + app/ServiceProvider/ClassProvider.php | 40 +- app/ServiceProvider/GroupProvider.php | 37 + app/ServiceProvider/NotificationProvider.php | 45 + app/ServiceProvider/PluginProvider.php | 31 + app/ServiceProvider/RouteProvider.php | 151 + app/ServiceProvider/SessionProvider.php | 13 + app/Subscriber/AuthSubscriber.php | 90 +- app/Subscriber/BootstrapSubscriber.php | 18 +- app/Template/activity/project.php | 2 +- app/Template/analytic/layout.php | 2 +- app/Template/app/layout.php | 6 +- app/Template/app/projects.php | 2 +- app/Template/board/popover_assignee.php | 2 +- app/Template/board/popover_category.php | 2 +- app/Template/board/table_column.php | 2 +- app/Template/board/table_swimlane.php | 2 +- app/Template/board/task_footer.php | 14 +- app/Template/board/task_menu.php | 6 +- app/Template/board/task_private.php | 14 +- app/Template/calendar/show.php | 2 +- app/Template/custom_filter/add.php | 2 +- app/Template/custom_filter/edit.php | 2 +- app/Template/custom_filter/index.php | 2 +- app/Template/gantt/projects.php | 4 +- app/Template/group/dissociate.php | 2 - app/Template/group/index.php | 4 +- app/Template/group/remove.php | 2 - app/Template/group/users.php | 2 - app/Template/layout.php | 2 +- app/Template/project/dropdown.php | 11 +- app/Template/project/edit.php | 2 +- app/Template/project/filters.php | 2 +- app/Template/project/index.php | 40 +- app/Template/project/roles.php | 7 + app/Template/project/sidebar.php | 10 +- app/Template/project/users.php | 82 - app/Template/project_permission/index.php | 141 + app/Template/project_user/layout.php | 4 +- app/Template/subtask/show.php | 15 +- app/Template/task/layout.php | 2 +- app/Template/task/show.php | 5 +- app/Template/task/sidebar.php | 2 + app/Template/tasklink/create.php | 4 +- app/Template/tasklink/edit.php | 4 +- app/Template/tasklink/show.php | 4 +- app/Template/twofactor/index.php | 12 +- app/Template/user/create_local.php | 21 +- app/Template/user/create_remote.php | 23 +- app/Template/user/edit.php | 14 +- app/Template/user/external.php | 6 +- app/Template/user/index.php | 10 +- app/Template/user/layout.php | 2 +- app/Template/user/sessions.php | 2 +- app/Template/user/show.php | 2 +- app/Template/user/sidebar.php | 6 +- app/Template/user_import/step1.php | 2 +- app/User/DatabaseUserProvider.php | 144 + app/User/GithubUserProvider.php | 23 + app/User/GitlabUserProvider.php | 23 + app/User/GoogleUserProvider.php | 23 + app/User/LdapUserProvider.php | 206 + app/User/OAuthUserProvider.php | 141 + app/User/ReverseProxyUserProvider.php | 147 + app/common.php | 14 +- app/constants.php | 28 +- app/routes.php | 117 - assets/js/app.js | 2 +- assets/js/src/App.js | 29 +- assets/js/src/Project.js | 18 + composer.lock | 8 +- config.default.php | 67 +- doc/api-action-procedures.markdown | 245 ++ doc/api-application-procedures.markdown | 231 + doc/api-authentication.markdown | 66 + doc/api-board-procedures.markdown | 416 ++ doc/api-category-procedures.markdown | 172 + doc/api-comment-procedures.markdown | 182 + doc/api-examples.markdown | 184 + doc/api-file-procedures.markdown | 217 + doc/api-json-rpc.markdown | 4526 +------------------- doc/api-link-procedures.markdown | 470 ++ doc/api-me-procedures.markdown | 385 ++ doc/api-project-procedures.markdown | 512 +++ doc/api-subtask-procedures.markdown | 194 + doc/api-swimlane-procedures.markdown | 469 ++ doc/api-task-procedures.markdown | 564 +++ doc/api-user-procedures.markdown | 222 + kanboard | 2 +- tests/functionals/ApiTest.php | 19 + tests/units/Action/TaskAssignCurrentUserTest.php | 2 +- tests/units/Auth/DatabaseAuthTest.php | 52 + tests/units/Auth/LdapTest.php | 697 --- tests/units/Auth/ReverseProxyTest.php | 37 - tests/units/Auth/TotpAuthTest.php | 63 + tests/units/Base.php | 3 + tests/units/Core/Http/OAuth2Test.php | 43 + tests/units/Core/Http/RememberMeCookieTest.php | 108 + tests/units/Core/Http/RequestTest.php | 175 + tests/units/Core/Ldap/ClientTest.php | 53 +- tests/units/Core/Ldap/EntriesTest.php | 55 + tests/units/Core/Ldap/EntryTest.php | 71 + tests/units/Core/Ldap/LdapGroupTest.php | 160 + tests/units/Core/Ldap/LdapUserTest.php | 379 ++ tests/units/Core/Ldap/QueryTest.php | 45 +- tests/units/Core/Ldap/UserTest.php | 95 - tests/units/Core/OAuth2Test.php | 43 - tests/units/Core/Security/AccessMapTest.php | 29 +- .../Core/Security/AuthenticationManagerTest.php | 150 + tests/units/Core/Security/AuthorizationTest.php | 25 +- tests/units/Core/User/GroupSyncTest.php | 30 + tests/units/Core/User/UserProfileTest.php | 63 + tests/units/Core/User/UserPropertyTest.php | 60 + tests/units/Core/User/UserSessionTest.php | 144 + tests/units/Core/User/UserSyncTest.php | 55 + tests/units/Helper/UserHelperTest.php | 261 +- tests/units/Integration/BitbucketWebhookTest.php | 11 +- tests/units/Integration/GithubWebhookTest.php | 15 +- tests/units/Integration/GitlabWebhookTest.php | 7 +- tests/units/Model/AclTest.php | 296 -- tests/units/Model/ActionTest.php | 27 +- tests/units/Model/AuthenticationTest.php | 39 - tests/units/Model/ProjectDuplicationTest.php | 37 +- tests/units/Model/ProjectGroupRoleTest.php | 340 ++ tests/units/Model/ProjectPermissionTest.php | 318 +- tests/units/Model/ProjectTest.php | 1 - tests/units/Model/ProjectUserRoleTest.php | 400 ++ tests/units/Model/SubtaskTest.php | 2 +- tests/units/Model/TaskCreationTest.php | 1 - tests/units/Model/TaskDuplicationTest.php | 35 +- tests/units/Model/TaskFinderTest.php | 1 - tests/units/Model/TaskModificationTest.php | 1 - tests/units/Model/TaskPermissionTest.php | 2 +- tests/units/Model/TaskStatusTest.php | 1 - tests/units/Model/TaskTest.php | 1 - tests/units/Model/UserLockingTest.php | 43 + tests/units/Model/UserNotificationTest.php | 22 +- tests/units/Model/UserSessionTest.php | 158 - tests/units/Model/UserTest.php | 108 +- tests/units/Notification/MailTest.php | 1 - tests/units/User/DatabaseUserProviderTest.php | 14 + 259 files changed, 14513 insertions(+), 10219 deletions(-) create mode 100644 app/Auth/DatabaseAuth.php delete mode 100644 app/Auth/Github.php create mode 100644 app/Auth/GithubAuth.php delete mode 100644 app/Auth/Gitlab.php create mode 100644 app/Auth/GitlabAuth.php delete mode 100644 app/Auth/Google.php create mode 100644 app/Auth/GoogleAuth.php delete mode 100644 app/Auth/Ldap.php create mode 100644 app/Auth/LdapAuth.php delete mode 100644 app/Auth/RememberMe.php create mode 100644 app/Auth/RememberMeAuth.php delete mode 100644 app/Auth/ReverseProxy.php create mode 100644 app/Auth/ReverseProxyAuth.php create mode 100644 app/Auth/TotpAuth.php create mode 100644 app/Controller/BoardPopover.php create mode 100644 app/Controller/BoardTooltip.php create mode 100644 app/Controller/GroupHelper.php create mode 100644 app/Controller/ProjectPermission.php create mode 100644 app/Controller/TaskHelper.php create mode 100644 app/Controller/UserHelper.php create mode 100644 app/Core/Group/GroupBackendProviderInterface.php create mode 100644 app/Core/Group/GroupManager.php create mode 100644 app/Core/Group/GroupProviderInterface.php create mode 100644 app/Core/Http/OAuth2.php create mode 100644 app/Core/Http/RememberMeCookie.php create mode 100644 app/Core/Ldap/Entries.php create mode 100644 app/Core/Ldap/Entry.php create mode 100644 app/Core/Ldap/Group.php delete mode 100644 app/Core/OAuth2.php create mode 100644 app/Core/Security/AuthenticationManager.php create mode 100644 app/Core/Security/AuthenticationProviderInterface.php create mode 100644 app/Core/Security/OAuthAuthenticationProviderInterface.php create mode 100644 app/Core/Security/PasswordAuthenticationProviderInterface.php create mode 100644 app/Core/Security/PostAuthenticationProviderInterface.php create mode 100644 app/Core/Security/PreAuthenticationProviderInterface.php create mode 100644 app/Core/Security/SessionCheckProviderInterface.php create mode 100644 app/Core/User/GroupSync.php create mode 100644 app/Core/User/UserProfile.php create mode 100644 app/Core/User/UserProperty.php create mode 100644 app/Core/User/UserProviderInterface.php create mode 100644 app/Core/User/UserSession.php create mode 100644 app/Core/User/UserSync.php delete mode 100644 app/Event/AuthEvent.php create mode 100644 app/Event/AuthFailureEvent.php create mode 100644 app/Event/AuthSuccessEvent.php create mode 100644 app/Formatter/GroupAutoCompleteFormatter.php create mode 100644 app/Formatter/UserFilterAutoCompleteFormatter.php create mode 100644 app/Group/DatabaseBackendGroupProvider.php create mode 100644 app/Group/DatabaseGroupProvider.php create mode 100644 app/Group/LdapBackendGroupProvider.php create mode 100644 app/Group/LdapGroupProvider.php delete mode 100644 app/Model/Acl.php create mode 100644 app/Model/ProjectGroupRole.php create mode 100644 app/Model/ProjectUserRole.php create mode 100644 app/Model/RememberMeSession.php create mode 100644 app/Model/UserFilter.php create mode 100644 app/Model/UserLocking.php delete mode 100644 app/Model/UserSession.php create mode 100644 app/ServiceProvider/AuthenticationProvider.php create mode 100644 app/ServiceProvider/GroupProvider.php create mode 100644 app/ServiceProvider/NotificationProvider.php create mode 100644 app/ServiceProvider/PluginProvider.php create mode 100644 app/ServiceProvider/RouteProvider.php create mode 100644 app/Template/project/roles.php delete mode 100644 app/Template/project/users.php create mode 100644 app/Template/project_permission/index.php create mode 100644 app/User/DatabaseUserProvider.php create mode 100644 app/User/GithubUserProvider.php create mode 100644 app/User/GitlabUserProvider.php create mode 100644 app/User/GoogleUserProvider.php create mode 100644 app/User/LdapUserProvider.php create mode 100644 app/User/OAuthUserProvider.php create mode 100644 app/User/ReverseProxyUserProvider.php delete mode 100644 app/routes.php create mode 100644 assets/js/src/Project.js create mode 100644 doc/api-action-procedures.markdown create mode 100644 doc/api-application-procedures.markdown create mode 100644 doc/api-authentication.markdown create mode 100644 doc/api-board-procedures.markdown create mode 100644 doc/api-category-procedures.markdown create mode 100644 doc/api-comment-procedures.markdown create mode 100644 doc/api-examples.markdown create mode 100644 doc/api-file-procedures.markdown create mode 100644 doc/api-link-procedures.markdown create mode 100644 doc/api-me-procedures.markdown create mode 100644 doc/api-project-procedures.markdown create mode 100644 doc/api-subtask-procedures.markdown create mode 100644 doc/api-swimlane-procedures.markdown create mode 100644 doc/api-task-procedures.markdown create mode 100644 doc/api-user-procedures.markdown create mode 100644 tests/units/Auth/DatabaseAuthTest.php delete mode 100644 tests/units/Auth/LdapTest.php delete mode 100644 tests/units/Auth/ReverseProxyTest.php create mode 100644 tests/units/Auth/TotpAuthTest.php create mode 100644 tests/units/Core/Http/OAuth2Test.php create mode 100644 tests/units/Core/Http/RememberMeCookieTest.php create mode 100644 tests/units/Core/Http/RequestTest.php create mode 100644 tests/units/Core/Ldap/EntriesTest.php create mode 100644 tests/units/Core/Ldap/EntryTest.php create mode 100644 tests/units/Core/Ldap/LdapGroupTest.php create mode 100644 tests/units/Core/Ldap/LdapUserTest.php delete mode 100644 tests/units/Core/Ldap/UserTest.php delete mode 100644 tests/units/Core/OAuth2Test.php create mode 100644 tests/units/Core/Security/AuthenticationManagerTest.php create mode 100644 tests/units/Core/User/GroupSyncTest.php create mode 100644 tests/units/Core/User/UserProfileTest.php create mode 100644 tests/units/Core/User/UserPropertyTest.php create mode 100644 tests/units/Core/User/UserSessionTest.php create mode 100644 tests/units/Core/User/UserSyncTest.php delete mode 100644 tests/units/Model/AclTest.php delete mode 100644 tests/units/Model/AuthenticationTest.php create mode 100644 tests/units/Model/ProjectGroupRoleTest.php create mode 100644 tests/units/Model/ProjectUserRoleTest.php create mode 100644 tests/units/Model/UserLockingTest.php delete mode 100644 tests/units/Model/UserSessionTest.php create mode 100644 tests/units/User/DatabaseUserProviderTest.php (limited to 'tests/units') diff --git a/ChangeLog b/ChangeLog index 5a858885..230a6b28 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,13 +1,27 @@ Version 1.0.22 (unreleased) --------------------------- +Breaking changes: + +* LDAP configuration parameters changes (See documentation) +* SQL table changes: + - "users" table: added new column "role" and removed columns "is_admin" and "is_project_admin" + - "project_has_users" table: replace column "is_owner" by column "role" + - Sqlite does not support alter table, old columns still there but unused +* API procedure changes: + - createUser + - createLdapUser + - updateUser + New features: -* User groups (Teams) -* Add generic LDAP client library -* Pluggable authentication and authorization system (Work in progress) +* Add pluggable authentication and authorization system (Work in progress) +* Add groups (teams) +* Add LDAP groups synchronization +* Add project groups permissions * Add new project role Viewer (Work in progress) -* Assign project permissions to a group (Work in progress) +* Add generic LDAP client library +* Add search query attribute for task link Version 1.0.21 -------------- @@ -15,8 +29,8 @@ Version 1.0.21 Breaking changes: * Projects with duplicate name are now allowed: - For Postgres and Mysql the unique constraint is removed by database migration - However Sqlite does not support alter table, only new databases will have the unique constraint removed + - For Postgres and Mysql the unique constraint is removed by database migration + - However Sqlite does not support alter table, only new databases will have the unique constraint removed New features: diff --git a/Makefile b/Makefile index 6bc1af83..6dd38659 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ CSS_APP = $(addprefix assets/css/src/, $(addsuffix .css, base links title table CSS_PRINT = $(addprefix assets/css/src/, $(addsuffix .css, print links table board task comment subtask markdown)) CSS_VENDOR = $(addprefix assets/css/vendor/, $(addsuffix .css, jquery-ui.min jquery-ui-timepicker-addon.min chosen.min fullcalendar.min font-awesome.min c3.min)) -JS_APP = $(addprefix assets/js/src/, $(addsuffix .js, Popover Dropdown Tooltip Markdown Sidebar Search App Screenshot Calendar Board Swimlane Gantt Task TaskRepartitionChart UserRepartitionChart CumulativeFlowDiagram BurndownChart AvgTimeColumnChart TaskTimeColumnChart LeadCycleTimeChart Router)) +JS_APP = $(addprefix assets/js/src/, $(addsuffix .js, Popover Dropdown Tooltip Markdown Sidebar Search App Screenshot Calendar Board Swimlane Gantt Task Project TaskRepartitionChart UserRepartitionChart CumulativeFlowDiagram BurndownChart AvgTimeColumnChart TaskTimeColumnChart LeadCycleTimeChart Router)) JS_VENDOR = $(addprefix assets/js/vendor/, $(addsuffix .js, jquery-1.11.3.min jquery-ui.min jquery-ui-timepicker-addon.min jquery.ui.touch-punch.min chosen.jquery.min moment.min fullcalendar.min mousetrap.min mousetrap-global-bind.min)) JS_LANG = $(addprefix assets/js/vendor/lang/, $(addsuffix .js, cs da de es fi fr hu id it ja nl nb pl pt pt-br ru sv sr th tr zh-cn)) diff --git a/app/Api/Auth.php b/app/Api/Auth.php index a084d6eb..0a911796 100644 --- a/app/Api/Auth.php +++ b/app/Api/Auth.php @@ -3,7 +3,6 @@ namespace Kanboard\Api; use JsonRPC\AuthenticationFailure; -use Symfony\Component\EventDispatcher\Event; /** * Base class @@ -24,15 +23,43 @@ class Auth extends Base */ public function checkCredentials($username, $password, $class, $method) { - $this->container['dispatcher']->dispatch('api.bootstrap', new Event); + $this->container['dispatcher']->dispatch('app.bootstrap'); - if ($username !== 'jsonrpc' && ! $this->authentication->hasCaptcha($username) && $this->authentication->authenticate($username, $password)) { + if ($this->isUserAuthenticated($username, $password)) { $this->checkProcedurePermission(true, $method); $this->userSession->initialize($this->user->getByUsername($username)); - } elseif ($username === 'jsonrpc' && $password === $this->config->get('api_token')) { + } elseif ($this->isAppAuthenticated($username, $password)) { $this->checkProcedurePermission(false, $method); } else { throw new AuthenticationFailure('Wrong credentials'); } } + + /** + * Check user credentials + * + * @access public + * @param string $username + * @param string $password + * @return boolean + */ + private function isUserAuthenticated($username, $password) + { + return $username !== 'jsonrpc' && + ! $this->userLocking->isLocked($username) && + $this->authenticationManager->passwordAuthentication($username, $password); + } + + /** + * Check administrative credentials + * + * @access public + * @param string $username + * @param string $password + * @return boolean + */ + private function isAppAuthenticated($username, $password) + { + return $username === 'jsonrpc' && $password === $this->config->get('api_token'); + } } diff --git a/app/Api/Me.php b/app/Api/Me.php index 2c4161fd..37851731 100644 --- a/app/Api/Me.php +++ b/app/Api/Me.php @@ -20,7 +20,7 @@ class Me extends Base public function getMyDashboard() { $user_id = $this->userSession->getId(); - $projects = $this->project->getQueryColumnStats($this->projectPermission->getActiveMemberProjectIds($user_id))->findAll(); + $projects = $this->project->getQueryColumnStats($this->projectPermission->getActiveProjectIds($user_id))->findAll(); $tasks = $this->taskFinder->getUserQuery($user_id)->findAll(); return array( @@ -32,7 +32,7 @@ class Me extends Base public function getMyActivityStream() { - $project_ids = $this->projectPermission->getActiveMemberProjectIds($this->userSession->getId()); + $project_ids = $this->projectPermission->getActiveProjectIds($this->userSession->getId()); return $this->projectActivity->getProjects($project_ids, 100); } @@ -50,7 +50,7 @@ class Me extends Base public function getMyProjectsList() { - return $this->projectPermission->getMemberProjects($this->userSession->getId()); + return $this->projectUserRole->getProjectsByUser($this->userSession->getId()); } public function getMyOverdueTasks() @@ -60,7 +60,7 @@ class Me extends Base public function getMyProjects() { - $project_ids = $this->projectPermission->getActiveMemberProjectIds($this->userSession->getId()); + $project_ids = $this->projectPermission->getActiveProjectIds($this->userSession->getId()); $projects = $this->project->getAllByIds($project_ids); return $this->formatProjects($projects); diff --git a/app/Api/ProjectPermission.php b/app/Api/ProjectPermission.php index 80323395..d4408197 100644 --- a/app/Api/ProjectPermission.php +++ b/app/Api/ProjectPermission.php @@ -2,6 +2,8 @@ namespace Kanboard\Api; +use Kanboard\Core\Security\Role; + /** * ProjectPermission API controller * @@ -12,16 +14,16 @@ class ProjectPermission extends \Kanboard\Core\Base { public function getMembers($project_id) { - return $this->projectPermission->getMembers($project_id); + return $this->projectUserRole->getAllUsers($project_id); } public function revokeUser($project_id, $user_id) { - return $this->projectPermission->revokeMember($project_id, $user_id); + return $this->projectUserRole->removeUser($project_id, $user_id); } public function allowUser($project_id, $user_id) { - return $this->projectPermission->addMember($project_id, $user_id); + return $this->projectUserRole->addUser($project_id, $user_id, Role::PROJECT_MEMBER); } } diff --git a/app/Api/User.php b/app/Api/User.php index 105723d3..078c82f1 100644 --- a/app/Api/User.php +++ b/app/Api/User.php @@ -3,6 +3,10 @@ namespace Kanboard\Api; use Kanboard\Auth\Ldap; +use Kanboard\Core\Security\Role; +use Kanboard\Core\Ldap\Client as LdapClient; +use Kanboard\Core\Ldap\ClientException as LdapException; +use Kanboard\Core\Ldap\User as LdapUser; /** * User API controller @@ -27,7 +31,7 @@ class User extends \Kanboard\Core\Base return $this->user->remove($user_id); } - public function createUser($username, $password, $name = '', $email = '', $is_admin = 0, $is_project_admin = 0) + public function createUser($username, $password, $name = '', $email = '', $role = Role::APP_USER) { $values = array( 'username' => $username, @@ -35,44 +39,53 @@ class User extends \Kanboard\Core\Base 'confirmation' => $password, 'name' => $name, 'email' => $email, - 'is_admin' => $is_admin, - 'is_project_admin' => $is_project_admin, + 'role' => $role, ); list($valid, ) = $this->user->validateCreation($values); return $valid ? $this->user->create($values) : false; } - public function createLdapUser($username = '', $email = '', $is_admin = 0, $is_project_admin = 0) + public function createLdapUser($username) { - $ldap = new Ldap($this->container); - $user = $ldap->lookup($username, $email); + try { - if (! $user) { - return false; - } + $ldap = LdapClient::connect(); + $user = LdapUser::getUser($ldap, sprintf(LDAP_USER_FILTER, $username)); - $values = array( - 'username' => $user['username'], - 'name' => $user['name'], - 'email' => $user['email'], - 'is_ldap_user' => 1, - 'is_admin' => $is_admin, - 'is_project_admin' => $is_project_admin, - ); + if ($user === null) { + $this->logger->info('User not found in LDAP server'); + return false; + } - return $this->user->create($values); + if ($user->getUsername() === '') { + throw new LogicException('Username not found in LDAP profile, check the parameter LDAP_USER_ATTRIBUTE_USERNAME'); + } + + $values = array( + 'username' => $user->getUsername(), + 'name' => $user->getName(), + 'email' => $user->getEmail(), + 'role' => $user->getRole(), + 'is_ldap_user' => 1, + ); + + return $this->user->create($values); + + } catch (LdapException $e) { + $this->logger->error($e->getMessage()); + return false; + } } - public function updateUser($id, $username = null, $name = null, $email = null, $is_admin = null, $is_project_admin = null) + public function updateUser($id, $username = null, $name = null, $email = null, $role = null) { $values = array( 'id' => $id, 'username' => $username, 'name' => $name, 'email' => $email, - 'is_admin' => $is_admin, - 'is_project_admin' => $is_project_admin, + 'role' => $role, ); foreach ($values as $key => $value) { diff --git a/app/Auth/DatabaseAuth.php b/app/Auth/DatabaseAuth.php new file mode 100644 index 00000000..727afaf3 --- /dev/null +++ b/app/Auth/DatabaseAuth.php @@ -0,0 +1,125 @@ +db + ->table(User::TABLE) + ->columns('id', 'password') + ->eq('username', $this->username) + ->eq('disable_login_form', 0) + ->eq('is_ldap_user', 0) + ->findOne(); + + if (! empty($user) && password_verify($this->password, $user['password'])) { + $this->userInfo = $user; + return true; + } + + return false; + } + + /** + * Check if the user session is valid + * + * @access public + * @return boolean + */ + public function isValidSession() + { + return $this->user->exists($this->userSession->getId()); + } + + /** + * Get user object + * + * @access public + * @return null|\Kanboard\User\DatabaseUserProvider + */ + public function getUser() + { + if (empty($this->userInfo)) { + return null; + } + + return new DatabaseUserProvider($this->userInfo); + } + + /** + * Set username + * + * @access public + * @param string $username + */ + public function setUsername($username) + { + $this->username = $username; + } + + /** + * Set password + * + * @access public + * @param string $password + */ + public function setPassword($password) + { + $this->password = $password; + } +} diff --git a/app/Auth/Github.php b/app/Auth/Github.php deleted file mode 100644 index 4777152a..00000000 --- a/app/Auth/Github.php +++ /dev/null @@ -1,123 +0,0 @@ -user->getByGithubId($github_id); - - if (! empty($user)) { - $this->userSession->initialize($user); - $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); - return true; - } - - return false; - } - - /** - * Unlink a Github account for a given user - * - * @access public - * @param integer $user_id User id - * @return boolean - */ - public function unlink($user_id) - { - return $this->user->update(array( - 'id' => $user_id, - 'github_id' => '', - )); - } - - /** - * Update the user table based on the Github profile information - * - * @access public - * @param integer $user_id User id - * @param array $profile Github profile - * @return boolean - */ - public function updateUser($user_id, array $profile) - { - $user = $this->user->getById($user_id); - - return $this->user->update(array( - 'id' => $user_id, - 'github_id' => $profile['id'], - 'email' => empty($user['email']) ? $profile['email'] : $user['email'], - 'name' => empty($user['name']) ? $profile['name'] : $user['name'], - )); - } - - /** - * Get OAuth2 configured service - * - * @access public - * @return Kanboard\Core\OAuth2 - */ - public function getService() - { - if (empty($this->service)) { - $this->service = $this->oauth->createService( - GITHUB_CLIENT_ID, - GITHUB_CLIENT_SECRET, - $this->helper->url->to('oauth', 'github', array(), '', true), - GITHUB_OAUTH_AUTHORIZE_URL, - GITHUB_OAUTH_TOKEN_URL, - array() - ); - } - - return $this->service; - } - - /** - * Get Github profile - * - * @access public - * @param string $code - * @return array - */ - public function getProfile($code) - { - $this->getService()->getAccessToken($code); - - return $this->httpClient->getJson( - GITHUB_API_URL.'user', - array($this->getService()->getAuthorizationHeader()) - ); - } -} diff --git a/app/Auth/GithubAuth.php b/app/Auth/GithubAuth.php new file mode 100644 index 00000000..47da0413 --- /dev/null +++ b/app/Auth/GithubAuth.php @@ -0,0 +1,143 @@ +getProfile(); + + if (! empty($profile)) { + $this->userInfo = new GithubUserProvider($profile); + return true; + } + + return false; + } + + /** + * Set Code + * + * @access public + * @param string $code + * @return GithubAuth + */ + public function setCode($code) + { + $this->code = $code; + return $this; + } + + /** + * Get user object + * + * @access public + * @return null|GithubUserProvider + */ + public function getUser() + { + return $this->userInfo; + } + + /** + * Get configured OAuth2 service + * + * @access public + * @return \Kanboard\Core\Http\OAuth2 + */ + public function getService() + { + if (empty($this->service)) { + $this->service = $this->oauth->createService( + GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET, + $this->helper->url->to('oauth', 'github', array(), '', true), + GITHUB_OAUTH_AUTHORIZE_URL, + GITHUB_OAUTH_TOKEN_URL, + array() + ); + } + + return $this->service; + } + + /** + * Get Github profile + * + * @access private + * @return array + */ + private function getProfile() + { + $this->getService()->getAccessToken($this->code); + + return $this->httpClient->getJson( + GITHUB_API_URL.'user', + array($this->getService()->getAuthorizationHeader()) + ); + } + + /** + * Unlink user + * + * @access public + * @param integer $userId + * @return bool + */ + public function unlink($userId) + { + return $this->user->update(array('id' => $userId, 'github_id' => '')); + } +} diff --git a/app/Auth/Gitlab.php b/app/Auth/Gitlab.php deleted file mode 100644 index 698b59c3..00000000 --- a/app/Auth/Gitlab.php +++ /dev/null @@ -1,123 +0,0 @@ -user->getByGitlabId($gitlab_id); - - if (! empty($user)) { - $this->userSession->initialize($user); - $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); - return true; - } - - return false; - } - - /** - * Unlink a Gitlab account for a given user - * - * @access public - * @param integer $user_id User id - * @return boolean - */ - public function unlink($user_id) - { - return $this->user->update(array( - 'id' => $user_id, - 'gitlab_id' => '', - )); - } - - /** - * Update the user table based on the Gitlab profile information - * - * @access public - * @param integer $user_id User id - * @param array $profile Gitlab profile - * @return boolean - */ - public function updateUser($user_id, array $profile) - { - $user = $this->user->getById($user_id); - - return $this->user->update(array( - 'id' => $user_id, - 'gitlab_id' => $profile['id'], - 'email' => empty($user['email']) ? $profile['email'] : $user['email'], - 'name' => empty($user['name']) ? $profile['name'] : $user['name'], - )); - } - - /** - * Get OAuth2 configured service - * - * @access public - * @return Kanboard\Core\OAuth2 - */ - public function getService() - { - if (empty($this->service)) { - $this->service = $this->oauth->createService( - GITLAB_CLIENT_ID, - GITLAB_CLIENT_SECRET, - $this->helper->url->to('oauth', 'gitlab', array(), '', true), - GITLAB_OAUTH_AUTHORIZE_URL, - GITLAB_OAUTH_TOKEN_URL, - array() - ); - } - - return $this->service; - } - - /** - * Get Gitlab profile - * - * @access public - * @param string $code - * @return array - */ - public function getProfile($code) - { - $this->getService()->getAccessToken($code); - - return $this->httpClient->getJson( - GITLAB_API_URL.'user', - array($this->getService()->getAuthorizationHeader()) - ); - } -} diff --git a/app/Auth/GitlabAuth.php b/app/Auth/GitlabAuth.php new file mode 100644 index 00000000..df6e0176 --- /dev/null +++ b/app/Auth/GitlabAuth.php @@ -0,0 +1,143 @@ +getProfile(); + + if (! empty($profile)) { + $this->userInfo = new GitlabUserProvider($profile); + return true; + } + + return false; + } + + /** + * Set Code + * + * @access public + * @param string $code + * @return GitlabAuth + */ + public function setCode($code) + { + $this->code = $code; + return $this; + } + + /** + * Get user object + * + * @access public + * @return null|GitlabUserProvider + */ + public function getUser() + { + return $this->userInfo; + } + + /** + * Get configured OAuth2 service + * + * @access public + * @return \Kanboard\Core\Http\OAuth2 + */ + public function getService() + { + if (empty($this->service)) { + $this->service = $this->oauth->createService( + GITLAB_CLIENT_ID, + GITLAB_CLIENT_SECRET, + $this->helper->url->to('oauth', 'gitlab', array(), '', true), + GITLAB_OAUTH_AUTHORIZE_URL, + GITLAB_OAUTH_TOKEN_URL, + array() + ); + } + + return $this->service; + } + + /** + * Get Gitlab profile + * + * @access private + * @return array + */ + private function getProfile() + { + $this->getService()->getAccessToken($this->code); + + return $this->httpClient->getJson( + GITLAB_API_URL.'user', + array($this->getService()->getAuthorizationHeader()) + ); + } + + /** + * Unlink user + * + * @access public + * @param integer $userId + * @return bool + */ + public function unlink($userId) + { + return $this->user->update(array('id' => $userId, 'gitlab_id' => '')); + } +} diff --git a/app/Auth/Google.php b/app/Auth/Google.php deleted file mode 100644 index 6c1bc3cd..00000000 --- a/app/Auth/Google.php +++ /dev/null @@ -1,124 +0,0 @@ -user->getByGoogleId($google_id); - - if (! empty($user)) { - $this->userSession->initialize($user); - $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); - return true; - } - - return false; - } - - /** - * Unlink a Google account for a given user - * - * @access public - * @param integer $user_id User id - * @return boolean - */ - public function unlink($user_id) - { - return $this->user->update(array( - 'id' => $user_id, - 'google_id' => '', - )); - } - - /** - * Update the user table based on the Google profile information - * - * @access public - * @param integer $user_id User id - * @param array $profile Google profile - * @return boolean - */ - public function updateUser($user_id, array $profile) - { - $user = $this->user->getById($user_id); - - return $this->user->update(array( - 'id' => $user_id, - 'google_id' => $profile['id'], - 'email' => empty($user['email']) ? $profile['email'] : $user['email'], - 'name' => empty($user['name']) ? $profile['name'] : $user['name'], - )); - } - - /** - * Get OAuth2 configured service - * - * @access public - * @return KanboardCore\OAuth2 - */ - public function getService() - { - if (empty($this->service)) { - $this->service = $this->oauth->createService( - GOOGLE_CLIENT_ID, - GOOGLE_CLIENT_SECRET, - $this->helper->url->to('oauth', 'google', array(), '', true), - 'https://accounts.google.com/o/oauth2/auth', - 'https://accounts.google.com/o/oauth2/token', - array('https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile') - ); - } - - return $this->service; - } - - /** - * Get Google profile - * - * @access public - * @param string $code - * @return array - */ - public function getProfile($code) - { - $this->getService()->getAccessToken($code); - - return $this->httpClient->getJson( - 'https://www.googleapis.com/oauth2/v1/userinfo', - array($this->getService()->getAuthorizationHeader()) - ); - } -} diff --git a/app/Auth/GoogleAuth.php b/app/Auth/GoogleAuth.php new file mode 100644 index 00000000..0dc1c62f --- /dev/null +++ b/app/Auth/GoogleAuth.php @@ -0,0 +1,143 @@ +getProfile(); + + if (! empty($profile)) { + $this->userInfo = new GoogleUserProvider($profile); + return true; + } + + return false; + } + + /** + * Set Code + * + * @access public + * @param string $code + * @return GoogleAuth + */ + public function setCode($code) + { + $this->code = $code; + return $this; + } + + /** + * Get user object + * + * @access public + * @return null|GoogleUserProvider + */ + public function getUser() + { + return $this->userInfo; + } + + /** + * Get configured OAuth2 service + * + * @access public + * @return \Kanboard\Core\Http\OAuth2 + */ + public function getService() + { + if (empty($this->service)) { + $this->service = $this->oauth->createService( + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, + $this->helper->url->to('oauth', 'google', array(), '', true), + 'https://accounts.google.com/o/oauth2/auth', + 'https://accounts.google.com/o/oauth2/token', + array('https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile') + ); + } + + return $this->service; + } + + /** + * Get Google profile + * + * @access private + * @return array + */ + private function getProfile() + { + $this->getService()->getAccessToken($this->code); + + return $this->httpClient->getJson( + 'https://www.googleapis.com/oauth2/v1/userinfo', + array($this->getService()->getAuthorizationHeader()) + ); + } + + /** + * Unlink user + * + * @access public + * @param integer $userId + * @return bool + */ + public function unlink($userId) + { + return $this->user->update(array('id' => $userId, 'google_id' => '')); + } +} diff --git a/app/Auth/Ldap.php b/app/Auth/Ldap.php deleted file mode 100644 index 3d361aa7..00000000 --- a/app/Auth/Ldap.php +++ /dev/null @@ -1,521 +0,0 @@ -getLdapAccountId(), - $this->getLdapAccountName(), - $this->getLdapAccountEmail(), - $this->getLdapAccountMemberOf() - ))); - } - - /** - * Authenticate the user - * - * @access public - * @param string $username Username - * @param string $password Password - * @return boolean - */ - public function authenticate($username, $password) - { - $username = $this->isLdapAccountCaseSensitive() ? $username : strtolower($username); - $result = $this->findUser($username, $password); - - if (is_array($result)) { - $user = $this->user->getByUsername($username); - - if (! empty($user)) { - - // There is already a local user with that name - if ($user['is_ldap_user'] == 0) { - return false; - } - } else { - - // We create automatically a new user - if ($this->isLdapAccountCreationEnabled() && $this->user->create($result) !== false) { - $user = $this->user->getByUsername($username); - } else { - return false; - } - } - - // We open the session - $this->userSession->initialize($user); - $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); - - return true; - } - - return false; - } - - /** - * Find the user from the LDAP server - * - * @access public - * @param string $username Username - * @param string $password Password - * @return boolean|array - */ - public function findUser($username, $password) - { - $ldap = $this->connect(); - - if ($ldap !== false && $this->bind($ldap, $username, $password)) { - return $this->getProfile($ldap, $username, $password); - } - - return false; - } - - /** - * LDAP connection - * - * @access public - * @return resource|boolean - */ - public function connect() - { - if (! function_exists('ldap_connect')) { - $this->logger->error('LDAP: The PHP LDAP extension is required'); - return false; - } - - // Skip SSL certificate verification - if (! LDAP_SSL_VERIFY) { - putenv('LDAPTLS_REQCERT=never'); - } - - $ldap = ldap_connect($this->getLdapServer(), $this->getLdapPort()); - - if ($ldap === false) { - $this->logger->error('LDAP: Unable to connect to the LDAP server'); - return false; - } - - ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3); - ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0); - ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 1); - ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 1); - - if (LDAP_START_TLS && ! @ldap_start_tls($ldap)) { - $this->logger->error('LDAP: Unable to use ldap_start_tls()'); - return false; - } - - return $ldap; - } - - /** - * LDAP authentication - * - * @access public - * @param resource $ldap - * @param string $username - * @param string $password - * @return boolean - */ - public function bind($ldap, $username, $password) - { - if ($this->getLdapBindType() === 'user') { - $ldap_username = sprintf($this->getLdapUsername(), $username); - $ldap_password = $password; - } elseif ($this->getLdapBindType() === 'proxy') { - $ldap_username = $this->getLdapUsername(); - $ldap_password = $this->getLdapPassword(); - } else { - $ldap_username = null; - $ldap_password = null; - } - - if (! @ldap_bind($ldap, $ldap_username, $ldap_password)) { - $this->logger->error('LDAP: Unable to bind to server with: '.$ldap_username); - $this->logger->error('LDAP: bind type='.$this->getLdapBindType()); - return false; - } - - return true; - } - - /** - * Get LDAP user profile - * - * @access public - * @param resource $ldap - * @param string $username - * @param string $password - * @return boolean|array - */ - public function getProfile($ldap, $username, $password) - { - $user_pattern = $this->getLdapUserPattern($username); - $entries = $this->executeQuery($ldap, $user_pattern); - - if ($entries === false) { - $this->logger->error('LDAP: Unable to get user profile: '.$user_pattern); - return false; - } - - if (@ldap_bind($ldap, $entries[0]['dn'], $password)) { - return $this->prepareProfile($ldap, $entries, $username); - } - - if (DEBUG) { - $this->logger->debug('LDAP: wrong password for '.$entries[0]['dn']); - } - - return false; - } - - /** - * Build user profile from LDAP information - * - * @access public - * @param resource $ldap - * @param array $entries - * @param string $username - * @return boolean|array - */ - public function prepareProfile($ldap, array $entries, $username) - { - if ($this->getLdapAccountId() !== '') { - $username = $this->getEntry($entries, $this->getLdapAccountId(), $username); - } - - return array( - 'username' => $username, - 'name' => $this->getEntry($entries, $this->getLdapAccountName()), - 'email' => $this->getEntry($entries, $this->getLdapAccountEmail()), - 'is_admin' => (int) $this->isMemberOf($this->getEntries($entries, $this->getLdapAccountMemberOf()), $this->getLdapGroupAdmin()), - 'is_project_admin' => (int) $this->isMemberOf($this->getEntries($entries, $this->getLdapAccountMemberOf()), $this->getLdapGroupProjectAdmin()), - 'is_ldap_user' => 1, - ); - } - - /** - * Check group membership - * - * @access public - * @param array $group_entries - * @param string $group_dn - * @return boolean - */ - public function isMemberOf(array $group_entries, $group_dn) - { - if (! isset($group_entries['count']) || empty($group_dn)) { - return false; - } - - for ($i = 0; $i < $group_entries['count']; $i++) { - if ($group_entries[$i] === $group_dn) { - return true; - } - } - - return false; - } - - /** - * Retrieve info on LDAP user by username or email - * - * @access public - * @param string $username - * @param string $email - * @return boolean|array - */ - public function lookup($username = null, $email = null) - { - $query = $this->getLookupQuery($username, $email); - if ($query === '') { - return false; - } - - // Connect and attempt anonymous or proxy binding - $ldap = $this->connect(); - if ($ldap === false || ! $this->bind($ldap, null, null)) { - return false; - } - - // Try to find user - $entries = $this->executeQuery($ldap, $query); - if ($entries === false) { - return false; - } - - // User id not retrieved: LDAP_ACCOUNT_ID not properly configured - if (empty($username) && ! isset($entries[0][$this->getLdapAccountId()][0])) { - return false; - } - - return $this->prepareProfile($ldap, $entries, $username); - } - - /** - * Execute LDAP query - * - * @access private - * @param resource $ldap - * @param string $query - * @return boolean|array - */ - private function executeQuery($ldap, $query) - { - $sr = @ldap_search($ldap, $this->getLdapBaseDn(), $query, $this->getProfileAttributes()); - if ($sr === false) { - return false; - } - - $entries = ldap_get_entries($ldap, $sr); - if ($entries === false || count($entries) === 0 || $entries['count'] == 0) { - return false; - } - - return $entries; - } - - /** - * Get the LDAP query to find a user - * - * @access private - * @param string $username - * @param string $email - * @return string - */ - private function getLookupQuery($username, $email) - { - if (! empty($username) && ! empty($email)) { - return '(&('.$this->getLdapUserPattern($username).')('.$this->getLdapAccountEmail().'='.$email.'))'; - } elseif (! empty($username)) { - return $this->getLdapUserPattern($username); - } elseif (! empty($email)) { - return '('.$this->getLdapAccountEmail().'='.$email.')'; - } - - return ''; - } - - /** - * Return one entry from a list of entries - * - * @access private - * @param array $entries LDAP entries - * @param string $key Key - * @param string $default Default value if key not set in entry - * @return string - */ - private function getEntry(array $entries, $key, $default = '') - { - return isset($entries[0][$key][0]) ? $entries[0][$key][0] : $default; - } - - /** - * Return subset of entries - * - * @access private - * @param array $entries - * @param string $key - * @param array $default - * @return array - */ - private function getEntries(array $entries, $key, $default = array()) - { - return isset($entries[0][$key]) ? $entries[0][$key] : $default; - } -} diff --git a/app/Auth/LdapAuth.php b/app/Auth/LdapAuth.php new file mode 100644 index 00000000..eb66e54d --- /dev/null +++ b/app/Auth/LdapAuth.php @@ -0,0 +1,187 @@ +getLdapUsername(), $this->getLdapPassword()); + $user = LdapUser::getUser($ldap, $this->getLdapUserPattern()); + + if ($user === null) { + $this->logger->info('User not found in LDAP server'); + return false; + } + + if ($user->getUsername() === '') { + throw new LogicException('Username not found in LDAP profile, check the parameter LDAP_USER_ATTRIBUTE_USERNAME'); + } + + if ($ldap->authenticate($user->getDn(), $this->password)) { + $this->user = $user; + return true; + } + + } catch (LdapException $e) { + $this->logger->error($e->getMessage()); + } + + return false; + } + + /** + * Get user object + * + * @access public + * @return \Kanboard\User\LdapUserProvider + */ + public function getUser() + { + return $this->user; + } + + /** + * Set username + * + * @access public + * @param string $username + */ + public function setUsername($username) + { + $this->username = $username; + } + + /** + * Set password + * + * @access public + * @param string $password + */ + public function setPassword($password) + { + $this->password = $password; + } + + /** + * Get LDAP user pattern + * + * @access public + * @return string + */ + public function getLdapUserPattern() + { + if (! LDAP_USER_FILTER) { + throw new LogicException('LDAP user filter empty, check the parameter LDAP_USER_FILTER'); + } + + return sprintf(LDAP_USER_FILTER, $this->username); + } + + /** + * Get LDAP username (proxy auth) + * + * @access public + * @return string + */ + public function getLdapUsername() + { + switch ($this->getLdapBindType()) { + case 'proxy': + return LDAP_USERNAME; + case 'user': + return sprintf(LDAP_USERNAME, $this->username); + default: + return null; + } + } + + /** + * Get LDAP password (proxy auth) + * + * @access public + * @return string + */ + public function getLdapPassword() + { + switch ($this->getLdapBindType()) { + case 'proxy': + return LDAP_PASSWORD; + case 'user': + return $this->password; + default: + return null; + } + } + + /** + * Get LDAP bind type + * + * @access public + * @return integer + */ + public function getLdapBindType() + { + if (LDAP_BIND_TYPE !== 'user' && LDAP_BIND_TYPE !== 'proxy' && LDAP_BIND_TYPE !== 'anonymous') { + throw new LogicException('Wrong value for the parameter LDAP_BIND_TYPE'); + } + + return LDAP_BIND_TYPE; + } +} diff --git a/app/Auth/RememberMe.php b/app/Auth/RememberMe.php deleted file mode 100644 index 0a567cbe..00000000 --- a/app/Auth/RememberMe.php +++ /dev/null @@ -1,323 +0,0 @@ -db - ->table(self::TABLE) - ->eq('token', $token) - ->eq('sequence', $sequence) - ->gt('expiration', time()) - ->findOne(); - } - - /** - * Get all sessions for a given user - * - * @access public - * @param integer $user_id User id - * @return array - */ - public function getAll($user_id) - { - return $this->db - ->table(self::TABLE) - ->eq('user_id', $user_id) - ->desc('date_creation') - ->columns('id', 'ip', 'user_agent', 'date_creation', 'expiration') - ->findAll(); - } - - /** - * Authenticate the user with the cookie - * - * @access public - * @return bool - */ - public function authenticate() - { - $credentials = $this->readCookie(); - - if ($credentials !== false) { - $record = $this->find($credentials['token'], $credentials['sequence']); - - if ($record) { - - // Update the sequence - $this->writeCookie( - $record['token'], - $this->update($record['token']), - $record['expiration'] - ); - - // Create the session - $this->userSession->initialize($this->user->getById($record['user_id'])); - - // Do not ask 2FA for remember me session - $this->sessionStorage->postAuth['validated'] = true; - - $this->container['dispatcher']->dispatch( - 'auth.success', - new AuthEvent(self::AUTH_NAME, $this->userSession->getId()) - ); - - return true; - } - } - - return false; - } - - /** - * Remove a session record - * - * @access public - * @param integer $session_id Session id - * @return mixed - */ - public function remove($session_id) - { - return $this->db - ->table(self::TABLE) - ->eq('id', $session_id) - ->remove(); - } - - /** - * Remove the current RememberMe session and the cookie - * - * @access public - * @param integer $user_id User id - */ - public function destroy($user_id) - { - $credentials = $this->readCookie(); - - if ($credentials !== false) { - $this->deleteCookie(); - - $this->db - ->table(self::TABLE) - ->eq('user_id', $user_id) - ->eq('token', $credentials['token']) - ->remove(); - } - } - - /** - * Create a new RememberMe session - * - * @access public - * @param integer $user_id User id - * @param string $ip IP Address - * @param string $user_agent User Agent - * @return array - */ - public function create($user_id, $ip, $user_agent) - { - $token = hash('sha256', $user_id.$user_agent.$ip.Token::getToken()); - $sequence = Token::getToken(); - $expiration = time() + self::EXPIRATION; - - $this->cleanup($user_id); - - $this - ->db - ->table(self::TABLE) - ->insert(array( - 'user_id' => $user_id, - 'ip' => $ip, - 'user_agent' => $user_agent, - 'token' => $token, - 'sequence' => $sequence, - 'expiration' => $expiration, - 'date_creation' => time(), - )); - - return array( - 'token' => $token, - 'sequence' => $sequence, - 'expiration' => $expiration, - ); - } - - /** - * Remove old sessions for a given user - * - * @access public - * @param integer $user_id User id - * @return bool - */ - public function cleanup($user_id) - { - return $this->db - ->table(self::TABLE) - ->eq('user_id', $user_id) - ->lt('expiration', time()) - ->remove(); - } - - /** - * Return a new sequence token and update the database - * - * @access public - * @param string $token Session token - * @return string - */ - public function update($token) - { - $new_sequence = Token::getToken(); - - $this->db - ->table(self::TABLE) - ->eq('token', $token) - ->update(array('sequence' => $new_sequence)); - - return $new_sequence; - } - - /** - * Encode the cookie - * - * @access public - * @param string $token Session token - * @param string $sequence Sequence token - * @return string - */ - public function encodeCookie($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 decodeCookie($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 ! empty($_COOKIE[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 - */ - public function writeCookie($token, $sequence, $expiration) - { - setcookie( - self::COOKIE_NAME, - $this->encodeCookie($token, $sequence), - $expiration, - $this->helper->url->dir(), - null, - Request::isHTTPS(), - true - ); - } - - /** - * Read and decode the cookie - * - * @access public - * @return mixed - */ - public function readCookie() - { - if (empty($_COOKIE[self::COOKIE_NAME])) { - return false; - } - - return $this->decodeCookie($_COOKIE[self::COOKIE_NAME]); - } - - /** - * Remove the cookie - * - * @access public - */ - public function deleteCookie() - { - setcookie( - self::COOKIE_NAME, - '', - time() - 3600, - $this->helper->url->dir(), - null, - Request::isHTTPS(), - true - ); - } -} diff --git a/app/Auth/RememberMeAuth.php b/app/Auth/RememberMeAuth.php new file mode 100644 index 00000000..02b7b9f6 --- /dev/null +++ b/app/Auth/RememberMeAuth.php @@ -0,0 +1,79 @@ +rememberMeCookie->read(); + + if ($credentials !== false) { + $session = $this->rememberMeSession->find($credentials['token'], $credentials['sequence']); + + if (! empty($session)) { + $this->rememberMeCookie->write( + $session['token'], + $this->rememberMeSession->updateSequence($session['token']), + $session['expiration'] + ); + + $this->userInfo = $this->user->getById($session['user_id']); + + return true; + } + } + + return false; + } + + /** + * Get user object + * + * @access public + * @return null|DatabaseUserProvider + */ + public function getUser() + { + if (empty($this->userInfo)) { + return null; + } + + return new DatabaseUserProvider($this->userInfo); + } +} diff --git a/app/Auth/ReverseProxy.php b/app/Auth/ReverseProxy.php deleted file mode 100644 index d119ca98..00000000 --- a/app/Auth/ReverseProxy.php +++ /dev/null @@ -1,83 +0,0 @@ -user->getByUsername($login); - - if (empty($user)) { - $this->createUser($login); - $user = $this->user->getByUsername($login); - } - - $this->userSession->initialize($user); - $this->container['dispatcher']->dispatch('auth.success', new AuthEvent(self::AUTH_NAME, $user['id'])); - - return true; - } - - return false; - } - - /** - * Create automatically a new local user after the authentication - * - * @access private - * @param string $login Username - * @return bool - */ - private function createUser($login) - { - $email = strpos($login, '@') !== false ? $login : ''; - - if (REVERSE_PROXY_DEFAULT_DOMAIN !== '' && empty($email)) { - $email = $login.'@'.REVERSE_PROXY_DEFAULT_DOMAIN; - } - - return $this->user->create(array( - 'email' => $email, - 'username' => $login, - 'is_admin' => REVERSE_PROXY_DEFAULT_ADMIN === $login, - 'is_ldap_user' => 1, - 'disable_login_form' => 1, - )); - } -} diff --git a/app/Auth/ReverseProxyAuth.php b/app/Auth/ReverseProxyAuth.php new file mode 100644 index 00000000..8af7f0a2 --- /dev/null +++ b/app/Auth/ReverseProxyAuth.php @@ -0,0 +1,76 @@ +request->getRemoteUser(); + + if (! empty($username)) { + $this->user = new ReverseProxyUserProvider($username); + return true; + } + + return false; + } + + /** + * Check if the user session is valid + * + * @access public + * @return boolean + */ + public function isValidSession() + { + return $this->request->getRemoteUser() === $this->userSession->getUsername(); + } + + /** + * Get user object + * + * @access public + * @return null|ReverseProxyUserProvider + */ + public function getUser() + { + return $this->user; + } +} diff --git a/app/Auth/TotpAuth.php b/app/Auth/TotpAuth.php new file mode 100644 index 00000000..f41fabd8 --- /dev/null +++ b/app/Auth/TotpAuth.php @@ -0,0 +1,126 @@ +checkTotp(Base32::decode($this->secret), $this->code); + } + + /** + * Set validation code + * + * @access public + * @param string $code + */ + public function setCode($code) + { + $this->code = $code; + } + + /** + * Set secret token + * + * @access public + * @param string $secret + */ + public function setSecret($secret) + { + $this->secret = $secret; + } + + /** + * Get secret token + * + * @access public + * @return string + */ + public function getSecret() + { + if (empty($this->secret)) { + $this->secret = GoogleAuthenticator::generateRandom(); + } + + return $this->secret; + } + + /** + * Get QR code url + * + * @access public + * @param string $label + * @return string + */ + public function getQrCodeUrl($label) + { + if (empty($this->secret)) { + return ''; + } + + return GoogleAuthenticator::getQrCodeUrl('totp', $label, $this->secret); + } + + /** + * Get key url (empty if no url can be provided) + * + * @access public + * @param string $label + * @return string + */ + public function getKeyUrl($label) + { + if (empty($this->secret)) { + return ''; + } + + return GoogleAuthenticator::getKeyUri('totp', $label, $this->secret); + } +} diff --git a/app/Controller/Action.php b/app/Controller/Action.php index ad136067..3caea45c 100644 --- a/app/Controller/Action.php +++ b/app/Controller/Action.php @@ -27,7 +27,7 @@ class Action extends Base 'available_events' => $this->action->getAvailableEvents(), 'available_params' => $this->action->getAllActionParameters(), 'columns_list' => $this->board->getColumnsList($project['id']), - 'users_list' => $this->projectPermission->getMemberList($project['id']), + 'users_list' => $this->projectUserRole->getAssignableUsersList($project['id']), 'projects_list' => $this->project->getList(false), 'colors_list' => $this->color->getList(), 'categories_list' => $this->category->getList($project['id']), @@ -86,7 +86,7 @@ class Action extends Base 'values' => $values, 'action_params' => $action_params, 'columns_list' => $this->board->getColumnsList($project['id']), - 'users_list' => $this->projectPermission->getMemberList($project['id']), + 'users_list' => $this->projectUserRole->getAssignableUsersList($project['id']), 'projects_list' => $projects_list, 'colors_list' => $this->color->getList(), 'categories_list' => $this->category->getList($project['id']), diff --git a/app/Controller/Activity.php b/app/Controller/Activity.php index 24327c23..71d5e94f 100644 --- a/app/Controller/Activity.php +++ b/app/Controller/Activity.php @@ -20,7 +20,7 @@ class Activity extends Base $project = $this->getProject(); $this->response->html($this->template->layout('activity/project', array( - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), 'events' => $this->projectActivity->getProject($project['id']), 'project' => $project, 'title' => t('%s\'s activity', $project['name']) diff --git a/app/Controller/Analytic.php b/app/Controller/Analytic.php index 1082b462..e03d8cab 100644 --- a/app/Controller/Analytic.php +++ b/app/Controller/Analytic.php @@ -20,7 +20,7 @@ class Analytic extends Base */ private function layout($template, array $params) { - $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $params['board_selector'] = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); $params['content_for_sublayout'] = $this->template->render($template, $params); return $this->template->layout('analytic/layout', $params); @@ -132,6 +132,9 @@ class Analytic extends Base * Common method for CFD and Burdown chart * * @access private + * @param string $template + * @param string $column + * @param string $title */ private function commonAggregateMetrics($template, $column, $title) { diff --git a/app/Controller/App.php b/app/Controller/App.php index 2fae004c..c596b4a8 100644 --- a/app/Controller/App.php +++ b/app/Controller/App.php @@ -22,7 +22,7 @@ class App extends Base */ private function layout($template, array $params) { - $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $params['board_selector'] = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); $params['content_for_sublayout'] = $this->template->render($template, $params); return $this->template->layout('app/layout', $params); @@ -42,7 +42,7 @@ class App extends Base ->setUrl('app', $action, array('pagination' => 'projects', 'user_id' => $user_id)) ->setMax($max) ->setOrder('name') - ->setQuery($this->project->getQueryColumnStats($this->projectPermission->getActiveMemberProjectIds($user_id))) + ->setQuery($this->project->getQueryColumnStats($this->projectPermission->getActiveProjectIds($user_id))) ->calculateOnlyIf($this->request->getStringParam('pagination') === 'projects'); } @@ -169,7 +169,7 @@ class App extends Base $this->response->html($this->layout('app/activity', array( 'title' => t('My activity stream'), - 'events' => $this->projectActivity->getProjects($this->projectPermission->getActiveMemberProjectIds($user['id']), 100), + 'events' => $this->projectActivity->getProjects($this->projectPermission->getActiveProjectIds($user['id']), 100), 'user' => $user, ))); } @@ -202,49 +202,4 @@ class App extends Base 'user' => $user, ))); } - - /** - * Render Markdown text and reply with the HTML Code - * - * @access public - */ - public function preview() - { - $payload = $this->request->getJson(); - - if (empty($payload['text'])) { - $this->response->html('

    '.t('Nothing to preview...').'

    '); - } - - $this->response->html($this->helper->text->markdown($payload['text'])); - } - - /** - * Task autocompletion (Ajax) - * - * @access public - */ - public function autocomplete() - { - $search = $this->request->getStringParam('term'); - $projects = $this->projectPermission->getActiveMemberProjectIds($this->userSession->getId()); - - if (empty($projects)) { - $this->response->json(array()); - } - - $filter = $this->taskFilterAutoCompleteFormatter - ->create() - ->filterByProjects($projects) - ->excludeTasks(array($this->request->getIntegerParam('exclude_task_id'))); - - // Search by task id or by title - if (ctype_digit($search)) { - $filter->filterById($search); - } else { - $filter->filterByTitle($search); - } - - $this->response->json($filter->format()); - } } diff --git a/app/Controller/Auth.php b/app/Controller/Auth.php index b90e756d..cd1dd167 100644 --- a/app/Controller/Auth.php +++ b/app/Controller/Auth.php @@ -24,7 +24,7 @@ class Auth extends Base } $this->response->html($this->template->layout('auth/index', array( - 'captcha' => isset($values['username']) && $this->authentication->hasCaptcha($values['username']), + 'captcha' => ! empty($values['username']) && $this->userLocking->hasCaptcha($values['username']), 'errors' => $errors, 'values' => $values, 'no_layout' => true, @@ -40,18 +40,11 @@ class Auth extends Base public function check() { $values = $this->request->getValues(); + $this->sessionStorage->hasRememberMe = ! empty($values['remember_me']); list($valid, $errors) = $this->authentication->validateForm($values); if ($valid) { - if (isset($this->sessionStorage->redirectAfterLogin) - && ! empty($this->sessionStorage->redirectAfterLogin) - && ! filter_var($this->sessionStorage->redirectAfterLogin, FILTER_VALIDATE_URL)) { - $redirect = $this->sessionStorage->redirectAfterLogin; - unset($this->sessionStorage->redirectAfterLogin); - $this->response->redirect($redirect); - } - - $this->response->redirect($this->helper->url->to('app', 'index')); + $this->redirectAfterLogin(); } $this->login($values, $errors); @@ -64,7 +57,6 @@ class Auth extends Base */ public function logout() { - $this->authentication->backend('rememberMe')->destroy($this->userSession->getId()); $this->sessionManager->close(); $this->response->redirect($this->helper->url->to('auth', 'login')); } @@ -83,4 +75,20 @@ class Auth extends Base $this->sessionStorage->captcha = $builder->getPhrase(); $builder->output(); } + + /** + * Redirect the user after the authentication + * + * @access private + */ + private function redirectAfterLogin() + { + if (isset($this->sessionStorage->redirectAfterLogin) && ! empty($this->sessionStorage->redirectAfterLogin) && ! filter_var($this->sessionStorage->redirectAfterLogin, FILTER_VALIDATE_URL)) { + $redirect = $this->sessionStorage->redirectAfterLogin; + unset($this->sessionStorage->redirectAfterLogin); + $this->response->redirect($redirect); + } + + $this->response->redirect($this->helper->url->to('app', 'index')); + } } diff --git a/app/Controller/Base.php b/app/Controller/Base.php index 8630f00c..76948a0f 100644 --- a/app/Controller/Base.php +++ b/app/Controller/Base.php @@ -3,7 +3,7 @@ namespace Kanboard\Controller; use Pimple\Container; -use Symfony\Component\EventDispatcher\Event; +use Kanboard\Core\Security\Role; /** * Base controller @@ -14,36 +14,22 @@ use Symfony\Component\EventDispatcher\Event; abstract class Base extends \Kanboard\Core\Base { /** - * Constructor - * - * @access public - * @param \Pimple\Container $container - */ - public function __construct(Container $container) - { - $this->container = $container; - - if (DEBUG) { - $this->logger->debug('START_REQUEST='.$_SERVER['REQUEST_URI']); - } - } - - /** - * Destructor + * Method executed before each action * * @access public */ - public function __destruct() + public function beforeAction($controller, $action) { - if (DEBUG) { - foreach ($this->db->getLogMessages() as $message) { - $this->logger->debug($message); - } + $this->sessionManager->open(); + $this->dispatcher->dispatch('app.bootstrap'); + $this->sendHeaders($action); + $this->authenticationManager->checkCurrentSession(); - $this->logger->debug('SQL_QUERIES={nb}', array('nb' => $this->container['db']->nbQueries)); - $this->logger->debug('RENDERING={time}', array('time' => microtime(true) - @$_SERVER['REQUEST_TIME_FLOAT'])); - $this->logger->debug('MEMORY='.$this->helper->text->bytes(memory_get_usage())); - $this->logger->debug('END_REQUEST='.$_SERVER['REQUEST_URI']); + if (! $this->applicationAuthorization->isAllowed($controller, $action, Role::APP_PUBLIC)) { + $this->handleAuthentication(); + $this->handlePostAuthentication($controller, $action); + $this->checkApplicationAuthorization($controller, $action); + $this->checkProjectAuthorization($controller, $action); } } @@ -69,34 +55,14 @@ abstract class Base extends \Kanboard\Core\Base } } - /** - * Method executed before each action - * - * @access public - */ - public function beforeAction($controller, $action) - { - $this->sessionManager->open(); - $this->sendHeaders($action); - $this->container['dispatcher']->dispatch('session.bootstrap', new Event); - - if (! $this->acl->isPublicAction($controller, $action)) { - $this->handleAuthentication(); - $this->handle2FA($controller, $action); - $this->handleAuthorization($controller, $action); - - $this->sessionStorage->hasSubtaskInProgress = $this->subtask->hasSubtaskInProgress($this->userSession->getId()); - } - } - /** * Check authentication * - * @access public + * @access private */ - public function handleAuthentication() + private function handleAuthentication() { - if (! $this->authentication->isAuthenticated()) { + if (! $this->userSession->isLogged() && ! $this->authenticationManager->preAuthentication()) { if ($this->request->isAjax()) { $this->response->text('Not Authorized', 401); } @@ -107,15 +73,15 @@ abstract class Base extends \Kanboard\Core\Base } /** - * Check 2FA + * Handle Post-Authentication (2FA) * - * @access public + * @access private */ - public function handle2FA($controller, $action) + private function handlePostAuthentication($controller, $action) { $ignore = ($controller === 'twofactor' && in_array($action, array('code', 'check'))) || ($controller === 'auth' && $action === 'logout'); - if ($ignore === false && $this->userSession->has2FA() && ! $this->userSession->check2FA()) { + if ($ignore === false && $this->userSession->hasPostAuthentication() && ! $this->userSession->isPostAuthenticationValidated()) { if ($this->request->isAjax()) { $this->response->text('Not Authorized', 401); } @@ -125,11 +91,23 @@ abstract class Base extends \Kanboard\Core\Base } /** - * Check page access and authorization + * Check application authorization * - * @access public + * @access private + */ + private function checkApplicationAuthorization($controller, $action) + { + if (! $this->helper->user->hasAccess($controller, $action)) { + $this->forbidden(); + } + } + + /** + * Check project authorization + * + * @access private */ - public function handleAuthorization($controller, $action) + private function checkProjectAuthorization($controller, $action) { $project_id = $this->request->getIntegerParam('project_id'); $task_id = $this->request->getIntegerParam('task_id'); @@ -139,7 +117,7 @@ abstract class Base extends \Kanboard\Core\Base $project_id = $this->taskFinder->getProjectId($task_id); } - if (! $this->acl->isAllowed($controller, $action, $project_id)) { + if ($project_id > 0 && ! $this->helper->user->hasProjectAccess($controller, $action, $project_id)) { $this->forbidden(); } } @@ -147,10 +125,10 @@ abstract class Base extends \Kanboard\Core\Base /** * Application not found page (404 error) * - * @access public + * @access protected * @param boolean $no_layout Display the layout or not */ - public function notfound($no_layout = false) + protected function notfound($no_layout = false) { $this->response->html($this->template->layout('app/notfound', array( 'title' => t('Page not found'), @@ -161,11 +139,15 @@ abstract class Base extends \Kanboard\Core\Base /** * Application forbidden page * - * @access public + * @access protected * @param boolean $no_layout Display the layout or not */ - public function forbidden($no_layout = false) + protected function forbidden($no_layout = false) { + if ($this->request->isAjax()) { + $this->response->text('Not Authorized', 401); + } + $this->response->html($this->template->layout('app/forbidden', array( 'title' => t('Access Forbidden'), 'no_layout' => $no_layout, @@ -209,7 +191,7 @@ abstract class Base extends \Kanboard\Core\Base $content = $this->template->render($template, $params); $params['task_content_for_layout'] = $content; $params['title'] = $params['task']['project_name'].' > '.$params['task']['title']; - $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $params['board_selector'] = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); return $this->template->layout('task/layout', $params); } @@ -227,7 +209,7 @@ abstract class Base extends \Kanboard\Core\Base $content = $this->template->render($template, $params); $params['project_content_for_layout'] = $content; $params['title'] = $params['project']['name'] === $params['title'] ? $params['title'] : $params['project']['name'].' > '.$params['title']; - $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $params['board_selector'] = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); $params['sidebar_template'] = $sidebar_template; return $this->template->layout('project/layout', $params); @@ -300,12 +282,15 @@ abstract class Base extends \Kanboard\Core\Base * Common method to get project filters * * @access protected + * @param string $controller + * @param string $action + * @return array */ protected function getProjectFilters($controller, $action) { $project = $this->getProject(); $search = $this->request->getStringParam('search', $this->userSession->getFilters($project['id'])); - $board_selector = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $board_selector = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); unset($board_selector[$project['id']]); $filters = array( diff --git a/app/Controller/Board.php b/app/Controller/Board.php index 7442ff22..a75fea33 100644 --- a/app/Controller/Board.php +++ b/app/Controller/Board.php @@ -51,7 +51,7 @@ class Board extends Base $this->response->html($this->template->layout('board/view_private', array( 'categories_list' => $this->category->getList($params['project']['id'], false), - 'users_list' => $this->projectPermission->getMemberList($params['project']['id'], false), + 'users_list' => $this->projectUserRole->getAssignableUsersList($params['project']['id'], false), 'custom_filters_list' => $this->customFilter->getAll($params['project']['id'], $this->userSession->getId()), 'swimlanes' => $this->taskFilter->search($params['filters']['search'])->getBoard($params['project']['id']), 'description' => $params['project']['description'], @@ -142,195 +142,6 @@ class Board extends Base $this->response->html($this->renderBoard($project_id)); } - /** - * Get links on mouseover - * - * @access public - */ - public function tasklinks() - { - $task = $this->getTask(); - $this->response->html($this->template->render('board/tooltip_tasklinks', array( - 'links' => $this->taskLink->getAll($task['id']), - 'task' => $task, - ))); - } - - /** - * Get subtasks on mouseover - * - * @access public - */ - public function subtasks() - { - $task = $this->getTask(); - $this->response->html($this->template->render('board/tooltip_subtasks', array( - 'subtasks' => $this->subtask->getAll($task['id']), - 'task' => $task, - ))); - } - - /** - * Display all attachments during the task mouseover - * - * @access public - */ - public function attachments() - { - $task = $this->getTask(); - - $this->response->html($this->template->render('board/tooltip_files', array( - 'files' => $this->file->getAll($task['id']), - 'task' => $task, - ))); - } - - /** - * Display comments during a task mouseover - * - * @access public - */ - public function comments() - { - $task = $this->getTask(); - - $this->response->html($this->template->render('board/tooltip_comments', array( - 'comments' => $this->comment->getAll($task['id'], $this->userSession->getCommentSorting()) - ))); - } - - /** - * Display task description - * - * @access public - */ - public function description() - { - $task = $this->getTask(); - - $this->response->html($this->template->render('board/tooltip_description', array( - 'task' => $task - ))); - } - - /** - * Change a task assignee directly from the board - * - * @access public - */ - public function changeAssignee() - { - $task = $this->getTask(); - $project = $this->project->getById($task['project_id']); - - $this->response->html($this->template->render('board/popover_assignee', array( - 'values' => $task, - 'users_list' => $this->projectPermission->getMemberList($project['id']), - 'project' => $project, - ))); - } - - /** - * Validate an assignee modification - * - * @access public - */ - public function updateAssignee() - { - $values = $this->request->getValues(); - - list($valid, ) = $this->taskValidator->validateAssigneeModification($values); - - if ($valid && $this->taskModification->update($values)) { - $this->flash->success(t('Task updated successfully.')); - } else { - $this->flash->failure(t('Unable to update your task.')); - } - - $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $values['project_id']))); - } - - /** - * Change a task category directly from the board - * - * @access public - */ - public function changeCategory() - { - $task = $this->getTask(); - $project = $this->project->getById($task['project_id']); - - $this->response->html($this->template->render('board/popover_category', array( - 'values' => $task, - 'categories_list' => $this->category->getList($project['id']), - 'project' => $project, - ))); - } - - /** - * Validate a category modification - * - * @access public - */ - public function updateCategory() - { - $values = $this->request->getValues(); - - list($valid, ) = $this->taskValidator->validateCategoryModification($values); - - if ($valid && $this->taskModification->update($values)) { - $this->flash->success(t('Task updated successfully.')); - } else { - $this->flash->failure(t('Unable to update your task.')); - } - - $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $values['project_id']))); - } - - /** - * Screenshot popover - * - * @access public - */ - public function screenshot() - { - $task = $this->getTask(); - - $this->response->html($this->template->render('file/screenshot', array( - 'task' => $task, - 'redirect' => 'board', - ))); - } - - /** - * Get recurrence information on mouseover - * - * @access public - */ - public function recurrence() - { - $task = $this->getTask(); - - $this->response->html($this->template->render('task/recurring_info', array( - 'task' => $task, - 'recurrence_trigger_list' => $this->task->getRecurrenceTriggerList(), - 'recurrence_timeframe_list' => $this->task->getRecurrenceTimeframeList(), - 'recurrence_basedate_list' => $this->task->getRecurrenceBasedateList(), - ))); - } - - /** - * Display swimlane description in tooltip - * - * @access public - */ - public function swimlane() - { - $this->getProject(); - $swimlane = $this->swimlane->getById($this->request->getIntegerParam('swimlane_id')); - $this->response->html($this->template->render('board/tooltip_description', array('task' => $swimlane))); - } - /** * Enable collapsed mode * @@ -355,6 +166,7 @@ class Board extends Base * Change display mode * * @access private + * @param boolean $mode */ private function changeDisplayMode($mode) { @@ -372,6 +184,7 @@ class Board extends Base * Render board * * @access private + * @param integer $project_id */ private function renderBoard($project_id) { diff --git a/app/Controller/BoardPopover.php b/app/Controller/BoardPopover.php new file mode 100644 index 00000000..51ec9bc4 --- /dev/null +++ b/app/Controller/BoardPopover.php @@ -0,0 +1,101 @@ +getTask(); + $project = $this->project->getById($task['project_id']); + + $this->response->html($this->template->render('board/popover_assignee', array( + 'values' => $task, + 'users_list' => $this->projectUserRole->getAssignableUsersList($project['id']), + 'project' => $project, + ))); + } + + /** + * Validate an assignee modification + * + * @access public + */ + public function updateAssignee() + { + $values = $this->request->getValues(); + + list($valid, ) = $this->taskValidator->validateAssigneeModification($values); + + if ($valid && $this->taskModification->update($values)) { + $this->flash->success(t('Task updated successfully.')); + } else { + $this->flash->failure(t('Unable to update your task.')); + } + + $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $values['project_id']))); + } + + /** + * Change a task category directly from the board + * + * @access public + */ + public function changeCategory() + { + $task = $this->getTask(); + $project = $this->project->getById($task['project_id']); + + $this->response->html($this->template->render('board/popover_category', array( + 'values' => $task, + 'categories_list' => $this->category->getList($project['id']), + 'project' => $project, + ))); + } + + /** + * Validate a category modification + * + * @access public + */ + public function updateCategory() + { + $values = $this->request->getValues(); + + list($valid, ) = $this->taskValidator->validateCategoryModification($values); + + if ($valid && $this->taskModification->update($values)) { + $this->flash->success(t('Task updated successfully.')); + } else { + $this->flash->failure(t('Unable to update your task.')); + } + + $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $values['project_id']))); + } + + /** + * Screenshot popover + * + * @access public + */ + public function screenshot() + { + $task = $this->getTask(); + + $this->response->html($this->template->render('file/screenshot', array( + 'task' => $task, + 'redirect' => 'board', + ))); + } +} diff --git a/app/Controller/BoardTooltip.php b/app/Controller/BoardTooltip.php new file mode 100644 index 00000000..ed58a2f2 --- /dev/null +++ b/app/Controller/BoardTooltip.php @@ -0,0 +1,112 @@ +getTask(); + $this->response->html($this->template->render('board/tooltip_tasklinks', array( + 'links' => $this->taskLink->getAll($task['id']), + 'task' => $task, + ))); + } + + /** + * Get subtasks on mouseover + * + * @access public + */ + public function subtasks() + { + $task = $this->getTask(); + $this->response->html($this->template->render('board/tooltip_subtasks', array( + 'subtasks' => $this->subtask->getAll($task['id']), + 'task' => $task, + ))); + } + + /** + * Display all attachments during the task mouseover + * + * @access public + */ + public function attachments() + { + $task = $this->getTask(); + + $this->response->html($this->template->render('board/tooltip_files', array( + 'files' => $this->file->getAll($task['id']), + 'task' => $task, + ))); + } + + /** + * Display comments during a task mouseover + * + * @access public + */ + public function comments() + { + $task = $this->getTask(); + + $this->response->html($this->template->render('board/tooltip_comments', array( + 'comments' => $this->comment->getAll($task['id'], $this->userSession->getCommentSorting()) + ))); + } + + /** + * Display task description + * + * @access public + */ + public function description() + { + $task = $this->getTask(); + + $this->response->html($this->template->render('board/tooltip_description', array( + 'task' => $task + ))); + } + + /** + * Get recurrence information on mouseover + * + * @access public + */ + public function recurrence() + { + $task = $this->getTask(); + + $this->response->html($this->template->render('task/recurring_info', array( + 'task' => $task, + 'recurrence_trigger_list' => $this->task->getRecurrenceTriggerList(), + 'recurrence_timeframe_list' => $this->task->getRecurrenceTimeframeList(), + 'recurrence_basedate_list' => $this->task->getRecurrenceBasedateList(), + ))); + } + + /** + * Display swimlane description in tooltip + * + * @access public + */ + public function swimlane() + { + $this->getProject(); + $swimlane = $this->swimlane->getById($this->request->getIntegerParam('swimlane_id')); + $this->response->html($this->template->render('board/tooltip_description', array('task' => $swimlane))); + } +} diff --git a/app/Controller/Config.php b/app/Controller/Config.php index 49806144..c813c795 100644 --- a/app/Controller/Config.php +++ b/app/Controller/Config.php @@ -20,7 +20,7 @@ class Config extends Base */ private function layout($template, array $params) { - $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $params['board_selector'] = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); $params['values'] = $this->config->getAll(); $params['errors'] = array(); $params['config_content_for_layout'] = $this->template->render($template, $params); diff --git a/app/Controller/Currency.php b/app/Controller/Currency.php index 118b2c41..89e38569 100644 --- a/app/Controller/Currency.php +++ b/app/Controller/Currency.php @@ -20,7 +20,7 @@ class Currency extends Base */ private function layout($template, array $params) { - $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $params['board_selector'] = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); $params['config_content_for_layout'] = $this->template->render($template, $params); return $this->template->layout('config/layout', $params); diff --git a/app/Controller/Customfilter.php b/app/Controller/Customfilter.php index d6863103..ef75a837 100644 --- a/app/Controller/Customfilter.php +++ b/app/Controller/Customfilter.php @@ -137,7 +137,7 @@ class Customfilter extends Base { $user_id = $this->userSession->getId(); - if ($filter['user_id'] != $user_id && (! $this->projectPermission->isManager($project['id'], $user_id) || ! $this->userSession->isAdmin())) { + if ($filter['user_id'] != $user_id && ($this->projectUserRole->getUserRole($project['id'], $user_id) === Role::PROJECT_MANAGER || ! $this->userSession->isAdmin())) { $this->forbidden(); } } diff --git a/app/Controller/Doc.php b/app/Controller/Doc.php index 32413048..08561aa1 100644 --- a/app/Controller/Doc.php +++ b/app/Controller/Doc.php @@ -53,7 +53,7 @@ class Doc extends Base } $this->response->html($this->template->layout('doc/show', $this->readFile($filename) + array( - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), ))); } } diff --git a/app/Controller/Feed.php b/app/Controller/Feed.php index 95b81fb8..8457c383 100644 --- a/app/Controller/Feed.php +++ b/app/Controller/Feed.php @@ -25,10 +25,8 @@ class Feed extends Base $this->forbidden(true); } - $projects = $this->projectPermission->getActiveMemberProjects($user['id']); - $this->response->xml($this->template->render('feed/user', array( - 'events' => $this->projectActivity->getProjects(array_keys($projects)), + 'events' => $this->projectActivity->getProjects($this->projectPermission->getActiveProjectIds($user['id'])), 'user' => $user, ))); } diff --git a/app/Controller/Gantt.php b/app/Controller/Gantt.php index bd3d92f7..f3954a25 100644 --- a/app/Controller/Gantt.php +++ b/app/Controller/Gantt.php @@ -20,13 +20,13 @@ class Gantt extends Base if ($this->userSession->isAdmin()) { $project_ids = $this->project->getAllIds(); } else { - $project_ids = $this->projectPermission->getMemberProjectIds($this->userSession->getId()); + $project_ids = $this->projectPermission->getActiveProjectIds($this->userSession->getId()); } $this->response->html($this->template->layout('gantt/projects', array( 'projects' => $this->projectGanttFormatter->filter($project_ids)->format(), 'title' => t('Gantt chart for all projects'), - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), ))); } @@ -66,7 +66,7 @@ class Gantt extends Base } $this->response->html($this->template->layout('gantt/project', $params + array( - 'users_list' => $this->projectPermission->getMemberList($params['project']['id'], false), + 'users_list' => $this->projectUserRole->getAssignableUsersList($params['project']['id'], false), 'sorting' => $sorting, 'tasks' => $filter->format(), ))); @@ -109,7 +109,7 @@ class Gantt extends Base 'column_id' => $this->board->getFirstColumn($project['id']), 'position' => 1 ), - 'users_list' => $this->projectPermission->getMemberList($project['id'], true, false, true), + 'users_list' => $this->projectUserRole->getAssignableUsersList($project['id'], true, false, true), 'colors_list' => $this->color->getList(), 'categories_list' => $this->category->getList($project['id']), 'swimlanes_list' => $this->swimlane->getList($project['id'], false, true), diff --git a/app/Controller/Group.php b/app/Controller/Group.php index 4e81f6c1..22d49e61 100644 --- a/app/Controller/Group.php +++ b/app/Controller/Group.php @@ -25,7 +25,7 @@ class Group extends Base ->calculate(); $this->response->html($this->template->layout('group/index', array( - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), 'title' => t('Groups').' ('.$paginator->getTotal().')', 'paginator' => $paginator, ))); @@ -49,7 +49,7 @@ class Group extends Base ->calculate(); $this->response->html($this->template->layout('group/users', array( - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), 'title' => t('Members of %s', $group['name']).' ('.$paginator->getTotal().')', 'paginator' => $paginator, 'group' => $group, @@ -64,7 +64,7 @@ class Group extends Base public function create(array $values = array(), array $errors = array()) { $this->response->html($this->template->layout('group/create', array( - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), 'errors' => $errors, 'values' => $values, 'title' => t('New group') @@ -105,7 +105,7 @@ class Group extends Base } $this->response->html($this->template->layout('group/edit', array( - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), 'errors' => $errors, 'values' => $values, 'title' => t('Edit group') @@ -149,7 +149,7 @@ class Group extends Base } $this->response->html($this->template->layout('group/associate', array( - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), 'users' => $this->user->prepareList($this->groupMember->getNotMembers($group_id)), 'group' => $group, 'errors' => $errors, diff --git a/app/Controller/GroupHelper.php b/app/Controller/GroupHelper.php new file mode 100644 index 00000000..34f522a6 --- /dev/null +++ b/app/Controller/GroupHelper.php @@ -0,0 +1,24 @@ +request->getStringParam('term'); + $groups = $this->groupManager->find($search); + $this->response->json($this->groupAutoCompleteFormatter->setGroups($groups)->format()); + } +} diff --git a/app/Controller/Link.php b/app/Controller/Link.php index c7f18230..33ec6688 100644 --- a/app/Controller/Link.php +++ b/app/Controller/Link.php @@ -21,7 +21,7 @@ class Link extends Base */ private function layout($template, array $params) { - $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $params['board_selector'] = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); $params['config_content_for_layout'] = $this->template->render($template, $params); return $this->template->layout('config/layout', $params); diff --git a/app/Controller/Oauth.php b/app/Controller/Oauth.php index 39546148..ed901def 100644 --- a/app/Controller/Oauth.php +++ b/app/Controller/Oauth.php @@ -17,7 +17,7 @@ class Oauth extends Base */ public function google() { - $this->step1('google'); + $this->step1('Google'); } /** @@ -27,7 +27,7 @@ class Oauth extends Base */ public function github() { - $this->step1('github'); + $this->step1('Github'); } /** @@ -37,7 +37,7 @@ class Oauth extends Base */ public function gitlab() { - $this->step1('gitlab'); + $this->step1('Gitlab'); } /** @@ -45,12 +45,12 @@ class Oauth extends Base * * @access public */ - public function unlink($backend = '') + public function unlink() { - $backend = $this->request->getStringParam('backend', $backend); + $backend = $this->request->getStringParam('backend'); $this->checkCSRFParam(); - if ($this->authentication->backend($backend)->unlink($this->userSession->getId())) { + if ($this->authenticationManager->getProvider($backend)->unlink($this->userSession->getId())) { $this->flash->success(t('Your external account is not linked anymore to your profile.')); } else { $this->flash->failure(t('Unable to unlink your external account.')); @@ -63,15 +63,16 @@ class Oauth extends Base * Redirect to the provider if no code received * * @access private + * @param string $provider */ - private function step1($backend) + private function step1($provider) { $code = $this->request->getStringParam('code'); if (! empty($code)) { - $this->step2($backend, $code); + $this->step2($provider, $code); } else { - $this->response->redirect($this->authentication->backend($backend)->getService()->getAuthorizationUrl()); + $this->response->redirect($this->authenticationManager->getProvider($provider)->getService()->getAuthorizationUrl()); } } @@ -79,30 +80,35 @@ class Oauth extends Base * Link or authenticate the user * * @access private + * @param string $provider + * @param string $code */ - private function step2($backend, $code) + private function step2($provider, $code) { - $profile = $this->authentication->backend($backend)->getProfile($code); + $this->authenticationManager->getProvider($provider)->setCode($code); if ($this->userSession->isLogged()) { - $this->link($backend, $profile); + $this->link($provider); } - $this->authenticate($backend, $profile); + $this->authenticate($provider); } /** * Link the account * * @access private + * @param string $provider */ - private function link($backend, $profile) + private function link($provider) { - if (empty($profile)) { + $authProvider = $this->authenticationManager->getProvider($provider); + + if (! $authProvider->authenticate()) { $this->flash->failure(t('External authentication failed')); } else { + $this->userProfile->assign($this->userSession->getId(), $authProvider->getUser()); $this->flash->success(t('Your external account is linked to your profile successfully.')); - $this->authentication->backend($backend)->updateUser($this->userSession->getId(), $profile); } $this->response->redirect($this->helper->url->to('user', 'external', array('user_id' => $this->userSession->getId()))); @@ -112,10 +118,11 @@ class Oauth extends Base * Authenticate the account * * @access private + * @param string $provider */ - private function authenticate($backend, $profile) + private function authenticate($provider) { - if (! empty($profile) && $this->authentication->backend($backend)->authenticate($profile['id'])) { + if ($this->authenticationManager->oauthAuthentication($provider)) { $this->response->redirect($this->helper->url->to('app', 'index')); } else { $this->response->html($this->template->layout('auth/index', array( diff --git a/app/Controller/Project.php b/app/Controller/Project.php index 2d9c25de..80c95aa2 100644 --- a/app/Controller/Project.php +++ b/app/Controller/Project.php @@ -20,7 +20,7 @@ class Project extends Base if ($this->userSession->isAdmin()) { $project_ids = $this->project->getAllIds(); } else { - $project_ids = $this->projectPermission->getMemberProjectIds($this->userSession->getId()); + $project_ids = $this->projectPermission->getActiveProjectIds($this->userSession->getId()); } $nb_projects = count($project_ids); @@ -33,7 +33,7 @@ class Project extends Base ->calculate(); $this->response->html($this->template->layout('project/index', array( - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), 'paginator' => $paginator, 'nb_projects' => $nb_projects, 'title' => t('Projects').' ('.$nb_projects.')' @@ -160,11 +160,11 @@ class Project extends Base $values = $this->request->getValues(); if (isset($values['is_private'])) { - if (! $this->helper->user->isProjectAdministrationAllowed($project['id'])) { + if (! $this->helper->user->hasProjectAccess('project', 'create', $project['id'])) { unset($values['is_private']); } } elseif ($project['is_private'] == 1 && ! isset($values['is_private'])) { - if ($this->helper->user->isProjectAdministrationAllowed($project['id'])) { + if ($this->helper->user->hasProjectAccess('project', 'create', $project['id'])) { $values += array('is_private' => 0); } } @@ -183,120 +183,6 @@ class Project extends Base $this->edit($values, $errors); } - /** - * Users list for the selected project - * - * @access public - */ - public function users() - { - $project = $this->getProject(); - - $this->response->html($this->projectLayout('project/users', array( - 'project' => $project, - 'users' => $this->projectPermission->getAllUsers($project['id']), - 'title' => t('Edit project access list') - ))); - } - - /** - * Allow everybody - * - * @access public - */ - public function allowEverybody() - { - $project = $this->getProject(); - $values = $this->request->getValues() + array('is_everybody_allowed' => 0); - list($valid, ) = $this->projectPermission->validateProjectModification($values); - - if ($valid) { - if ($this->project->update($values)) { - $this->flash->success(t('Project updated successfully.')); - } else { - $this->flash->failure(t('Unable to update this project.')); - } - } - - $this->response->redirect($this->helper->url->to('project', 'users', array('project_id' => $project['id']))); - } - - /** - * Allow a specific user (admin only) - * - * @access public - */ - public function allow() - { - $values = $this->request->getValues(); - list($valid, ) = $this->projectPermission->validateUserModification($values); - - if ($valid) { - if ($this->projectPermission->addMember($values['project_id'], $values['user_id'])) { - $this->flash->success(t('Project updated successfully.')); - } else { - $this->flash->failure(t('Unable to update this project.')); - } - } - - $this->response->redirect($this->helper->url->to('project', 'users', array('project_id' => $values['project_id']))); - } - - /** - * Change the role of a project member - * - * @access public - */ - public function role() - { - $this->checkCSRFParam(); - - $values = array( - 'project_id' => $this->request->getIntegerParam('project_id'), - 'user_id' => $this->request->getIntegerParam('user_id'), - 'is_owner' => $this->request->getIntegerParam('is_owner'), - ); - - list($valid, ) = $this->projectPermission->validateUserModification($values); - - if ($valid) { - if ($this->projectPermission->changeRole($values['project_id'], $values['user_id'], $values['is_owner'])) { - $this->flash->success(t('Project updated successfully.')); - } else { - $this->flash->failure(t('Unable to update this project.')); - } - } - - $this->response->redirect($this->helper->url->to('project', 'users', array('project_id' => $values['project_id']))); - } - - /** - * Revoke user access (admin only) - * - * @access public - */ - public function revoke() - { - $this->checkCSRFParam(); - - $values = array( - 'project_id' => $this->request->getIntegerParam('project_id'), - 'user_id' => $this->request->getIntegerParam('user_id'), - ); - - list($valid, ) = $this->projectPermission->validateUserModification($values); - - if ($valid) { - if ($this->projectPermission->revokeMember($values['project_id'], $values['user_id'])) { - $this->flash->success(t('Project updated successfully.')); - } else { - $this->flash->failure(t('Unable to update this project.')); - } - } - - $this->response->redirect($this->helper->url->to('project', 'users', array('project_id' => $values['project_id']))); - } - /** * Remove a project * @@ -413,17 +299,28 @@ class Project extends Base */ public function create(array $values = array(), array $errors = array()) { - $is_private = $this->request->getIntegerParam('private', $this->userSession->isAdmin() || $this->userSession->isProjectAdmin() ? 0 : 1); + $is_private = isset($values['is_private']) && $values['is_private'] == 1; $this->response->html($this->template->layout('project/new', array( - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), - 'values' => empty($values) ? array('is_private' => $is_private) : $values, + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), + 'values' => $values, 'errors' => $errors, 'is_private' => $is_private, 'title' => $is_private ? t('New private project') : t('New project'), ))); } + /** + * Display a form to create a private project + * + * @access public + */ + public function createPrivate(array $values = array(), array $errors = array()) + { + $values['is_private'] = 1; + $this->create($values, $errors); + } + /** * Validate and save a new project * diff --git a/app/Controller/ProjectPermission.php b/app/Controller/ProjectPermission.php new file mode 100644 index 00000000..4434d017 --- /dev/null +++ b/app/Controller/ProjectPermission.php @@ -0,0 +1,177 @@ +getProject(); + + if (empty($values)) { + $values['role'] = Role::PROJECT_MEMBER; + } + + $this->response->html($this->projectLayout('project_permission/index', array( + 'project' => $project, + 'users' => $this->projectUserRole->getUsers($project['id']), + 'groups' => $this->projectGroupRole->getGroups($project['id']), + 'roles' => $this->role->getProjectRoles(), + 'values' => $values, + 'errors' => $errors, + 'title' => t('Project Permissions'), + ))); + } + + /** + * Allow everybody + * + * @access public + */ + public function allowEverybody() + { + $project = $this->getProject(); + $values = $this->request->getValues() + array('is_everybody_allowed' => 0); + + if ($this->project->update($values)) { + $this->flash->success(t('Project updated successfully.')); + } else { + $this->flash->failure(t('Unable to update this project.')); + } + + $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $project['id']))); + } + + /** + * Add user to the project + * + * @access public + */ + public function addUser() + { + $values = $this->request->getValues(); + + if ($this->projectUserRole->addUser($values['project_id'], $values['user_id'], $values['role'])) { + $this->flash->success(t('Project updated successfully.')); + } else { + $this->flash->failure(t('Unable to update this project.')); + } + + $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $values['project_id']))); + } + + /** + * Revoke user access + * + * @access public + */ + public function removeUser() + { + $this->checkCSRFParam(); + + $values = array( + 'project_id' => $this->request->getIntegerParam('project_id'), + 'user_id' => $this->request->getIntegerParam('user_id'), + ); + + if ($this->projectUserRole->removeUser($values['project_id'], $values['user_id'])) { + $this->flash->success(t('Project updated successfully.')); + } else { + $this->flash->failure(t('Unable to update this project.')); + } + + $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $values['project_id']))); + } + + /** + * Change user role + * + * @access public + */ + public function changeUserRole() + { + $project_id = $this->request->getIntegerParam('project_id'); + $values = $this->request->getJson(); + + if (! empty($project_id) && ! empty($values) && $this->projectUserRole->changeUserRole($project_id, $values['id'], $values['role'])) { + $this->response->json(array('status' => 'ok')); + } else { + $this->response->json(array('status' => 'error')); + } + } + + /** + * Add group to the project + * + * @access public + */ + public function addGroup() + { + $values = $this->request->getValues(); + + if (empty($values['group_id']) && ! empty($values['external_id'])) { + $values['group_id'] = $this->group->create($values['name'], $values['external_id']); + } + + if ($this->projectGroupRole->addGroup($values['project_id'], $values['group_id'], $values['role'])) { + $this->flash->success(t('Project updated successfully.')); + } else { + $this->flash->failure(t('Unable to update this project.')); + } + + $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $values['project_id']))); + } + + /** + * Revoke group access + * + * @access public + */ + public function removeGroup() + { + $this->checkCSRFParam(); + + $values = array( + 'project_id' => $this->request->getIntegerParam('project_id'), + 'group_id' => $this->request->getIntegerParam('group_id'), + ); + + if ($this->projectGroupRole->removeGroup($values['project_id'], $values['group_id'])) { + $this->flash->success(t('Project updated successfully.')); + } else { + $this->flash->failure(t('Unable to update this project.')); + } + + $this->response->redirect($this->helper->url->to('ProjectPermission', 'index', array('project_id' => $values['project_id']))); + } + + /** + * Change group role + * + * @access public + */ + public function changeGroupRole() + { + $project_id = $this->request->getIntegerParam('project_id'); + $values = $this->request->getJson(); + + if (! empty($project_id) && ! empty($values) && $this->projectGroupRole->changeGroupRole($project_id, $values['id'], $values['role'])) { + $this->response->json(array('status' => 'ok')); + } else { + $this->response->json(array('status' => 'error')); + } + } +} diff --git a/app/Controller/Projectuser.php b/app/Controller/Projectuser.php index 18829b3c..34595764 100644 --- a/app/Controller/Projectuser.php +++ b/app/Controller/Projectuser.php @@ -4,6 +4,7 @@ namespace Kanboard\Controller; use Kanboard\Model\User as UserModel; use Kanboard\Model\Task as TaskModel; +use Kanboard\Core\Security\Role; /** * Project User overview @@ -23,7 +24,7 @@ class Projectuser extends Base */ private function layout($template, array $params) { - $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $params['board_selector'] = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); $params['content_for_sublayout'] = $this->template->render($template, $params); $params['filter'] = array('user_id' => $params['user_id']); @@ -37,17 +38,17 @@ class Projectuser extends Base if ($this->userSession->isAdmin()) { $project_ids = $this->project->getAllIds(); } else { - $project_ids = $this->projectPermission->getMemberProjectIds($this->userSession->getId()); + $project_ids = $this->projectPermission->getActiveProjectIds($this->userSession->getId()); } return array($user_id, $project_ids, $this->user->getList(true)); } - private function role($is_owner, $action, $title, $title_user) + private function role($role, $action, $title, $title_user) { list($user_id, $project_ids, $users) = $this->common(); - $query = $this->projectPermission->getQueryByRole($project_ids, $is_owner)->callback(array($this->project, 'applyColumnStats')); + $query = $this->projectPermission->getQueryByRole($project_ids, $role)->callback(array($this->project, 'applyColumnStats')); if ($user_id !== UserModel::EVERYBODY_ID) { $query->eq(UserModel::TABLE.'.id', $user_id); @@ -101,7 +102,7 @@ class Projectuser extends Base */ public function managers() { - $this->role(1, 'managers', t('People who are project managers'), 'Projects where "%s" is manager'); + $this->role(Role::PROJECT_MANAGER, 'managers', t('People who are project managers'), 'Projects where "%s" is manager'); } /** @@ -110,7 +111,7 @@ class Projectuser extends Base */ public function members() { - $this->role(0, 'members', t('People who are project members'), 'Projects where "%s" is member'); + $this->role(ROLE::PROJECT_MEMBER, 'members', t('People who are project members'), 'Projects where "%s" is member'); } /** diff --git a/app/Controller/Search.php b/app/Controller/Search.php index 0aff9073..390210c0 100644 --- a/app/Controller/Search.php +++ b/app/Controller/Search.php @@ -12,7 +12,7 @@ class Search extends Base { public function index() { - $projects = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $projects = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); $search = urldecode($this->request->getStringParam('search')); $nb_tasks = 0; diff --git a/app/Controller/Subtask.php b/app/Controller/Subtask.php index 30ddc375..c93b637d 100644 --- a/app/Controller/Subtask.php +++ b/app/Controller/Subtask.php @@ -48,7 +48,7 @@ class Subtask extends Base $this->response->html($this->taskLayout('subtask/create', array( 'values' => $values, 'errors' => $errors, - 'users_list' => $this->projectPermission->getMemberList($task['project_id']), + 'users_list' => $this->projectUserRole->getAssignableUsersList($task['project_id']), 'task' => $task, ))); } @@ -95,7 +95,7 @@ class Subtask extends Base $this->response->html($this->taskLayout('subtask/edit', array( 'values' => empty($values) ? $subtask : $values, 'errors' => $errors, - 'users_list' => $this->projectPermission->getMemberList($task['project_id']), + 'users_list' => $this->projectUserRole->getAssignableUsersList($task['project_id']), 'status_list' => $this->subtask->getStatusList(), 'subtask' => $subtask, 'task' => $task, diff --git a/app/Controller/Task.php b/app/Controller/Task.php index e71b2017..1811dcb7 100644 --- a/app/Controller/Task.php +++ b/app/Controller/Task.php @@ -76,7 +76,7 @@ class Task extends Base 'link_label_list' => $this->link->getList(0, false), 'columns_list' => $this->board->getColumnsList($task['project_id']), 'colors_list' => $this->color->getList(), - 'users_list' => $this->projectPermission->getMemberList($task['project_id'], true, false, false), + 'users_list' => $this->projectUserRole->getAssignableUsersList($task['project_id'], true, false, false), 'date_format' => $this->config->get('application_date_format'), 'date_formats' => $this->dateParser->getAvailableFormats(), 'title' => $task['project_name'].' > '.$task['title'], diff --git a/app/Controller/TaskHelper.php b/app/Controller/TaskHelper.php new file mode 100644 index 00000000..236af33e --- /dev/null +++ b/app/Controller/TaskHelper.php @@ -0,0 +1,57 @@ +request->getJson(); + + if (empty($payload['text'])) { + $this->response->html('

    '.t('Nothing to preview...').'

    '); + } + + $this->response->html($this->helper->text->markdown($payload['text'])); + } + + /** + * Task autocompletion (Ajax) + * + * @access public + */ + public function autocomplete() + { + $search = $this->request->getStringParam('term'); + $projects = $this->projectPermission->getActiveProjectIds($this->userSession->getId()); + + if (empty($projects)) { + $this->response->json(array()); + } + + $filter = $this->taskFilterAutoCompleteFormatter + ->create() + ->filterByProjects($projects) + ->excludeTasks(array($this->request->getIntegerParam('exclude_task_id'))); + + // Search by task id or by title + if (ctype_digit($search)) { + $filter->filterById($search); + } else { + $filter->filterByTitle($search); + } + + $this->response->json($filter->format()); + } +} diff --git a/app/Controller/Taskcreation.php b/app/Controller/Taskcreation.php index cffa9d74..4d74fac6 100644 --- a/app/Controller/Taskcreation.php +++ b/app/Controller/Taskcreation.php @@ -36,7 +36,7 @@ class Taskcreation extends Base 'errors' => $errors, 'values' => $values + array('project_id' => $project['id']), 'columns_list' => $this->board->getColumnsList($project['id']), - 'users_list' => $this->projectPermission->getMemberList($project['id'], true, false, true), + 'users_list' => $this->projectUserRole->getAssignableUsersList($project['id'], true, false, true), 'colors_list' => $this->color->getList(), 'categories_list' => $this->category->getList($project['id']), 'swimlanes_list' => $swimlanes_list, diff --git a/app/Controller/Taskduplication.php b/app/Controller/Taskduplication.php index 9cd684eb..ae8bfcbc 100644 --- a/app/Controller/Taskduplication.php +++ b/app/Controller/Taskduplication.php @@ -2,6 +2,8 @@ namespace Kanboard\Controller; +use Kanboard\Model\Project as ProjectModel; + /** * Task Duplication controller * @@ -107,7 +109,7 @@ class Taskduplication extends Base private function chooseDestination(array $task, $template) { $values = array(); - $projects_list = $this->projectPermission->getActiveMemberProjects($this->userSession->getId()); + $projects_list = $this->projectUserRole->getProjectsByUser($this->userSession->getId(), array(ProjectModel::ACTIVE)); unset($projects_list[$task['project_id']]); @@ -117,7 +119,7 @@ class Taskduplication extends Base $swimlanes_list = $this->swimlane->getList($dst_project_id, false, true); $columns_list = $this->board->getColumnsList($dst_project_id); $categories_list = $this->category->getList($dst_project_id); - $users_list = $this->projectPermission->getMemberList($dst_project_id); + $users_list = $this->projectUserRole->getAssignableUsersList($dst_project_id); $values = $this->taskDuplication->checkDestinationProjectValues($task); $values['project_id'] = $dst_project_id; diff --git a/app/Controller/Taskmodification.php b/app/Controller/Taskmodification.php index 02b09a36..81cf430f 100644 --- a/app/Controller/Taskmodification.php +++ b/app/Controller/Taskmodification.php @@ -110,7 +110,7 @@ class Taskmodification extends Base 'values' => $values, 'errors' => $errors, 'task' => $task, - 'users_list' => $this->projectPermission->getMemberList($task['project_id']), + 'users_list' => $this->projectUserRole->getAssignableUsersList($task['project_id']), 'colors_list' => $this->color->getList(), 'categories_list' => $this->category->getList($task['project_id']), 'date_format' => $this->config->get('application_date_format'), diff --git a/app/Controller/Twofactor.php b/app/Controller/Twofactor.php index a7368d6b..aeb13acc 100644 --- a/app/Controller/Twofactor.php +++ b/app/Controller/Twofactor.php @@ -2,10 +2,6 @@ namespace Kanboard\Controller; -use Otp\Otp; -use Otp\GoogleAuthenticator; -use Base32\Base32; - /** * Two Factor Auth controller * @@ -36,12 +32,15 @@ class Twofactor extends User $user = $this->getUser(); $this->checkCurrentUser($user); + $provider = $this->authenticationManager->getPostAuthenticationProvider(); $label = $user['email'] ?: $user['username']; + $provider->setSecret($user['twofactor_secret']); + $this->response->html($this->layout('twofactor/index', array( 'user' => $user, - 'qrcode_url' => $user['twofactor_activated'] == 1 ? GoogleAuthenticator::getQrCodeUrl('totp', $label, $user['twofactor_secret']) : '', - 'key_url' => $user['twofactor_activated'] == 1 ? GoogleAuthenticator::getKeyUri('totp', $label, $user['twofactor_secret']) : '', + 'qrcode_url' => $user['twofactor_activated'] == 1 ? $provider->getQrCodeUrl($label) : '', + 'key_url' => $user['twofactor_activated'] == 1 ? $provider->getKeyUrl($label) : '', ))); } @@ -61,7 +60,7 @@ class Twofactor extends User $this->user->update(array( 'id' => $user['id'], 'twofactor_activated' => 1, - 'twofactor_secret' => GoogleAuthenticator::generateRandom(), + 'twofactor_secret' => $this->authenticationManager->getPostAuthenticationProvider()->getSecret(), )); } else { $this->user->update(array( @@ -72,14 +71,14 @@ class Twofactor extends User } // Allow the user to test or disable the feature - $this->userSession->disable2FA(); + $this->userSession->disablePostAuthentication(); $this->flash->success(t('User updated successfully.')); $this->response->redirect($this->helper->url->to('twofactor', 'index', array('user_id' => $user['id']))); } /** - * Test 2FA + * Test code * * @access public */ @@ -88,10 +87,13 @@ class Twofactor extends User $user = $this->getUser(); $this->checkCurrentUser($user); - $otp = new Otp; $values = $this->request->getValues(); - if (! empty($values['code']) && $otp->checkTotp(Base32::decode($user['twofactor_secret']), $values['code'])) { + $provider = $this->authenticationManager->getPostAuthenticationProvider(); + $provider->setCode(empty($values['code']) ? '' : $values['code']); + $provider->setSecret($user['twofactor_secret']); + + if ($provider->authenticate()) { $this->flash->success(t('The two factor authentication code is valid.')); } else { $this->flash->failure(t('The two factor authentication code is not valid.')); @@ -110,11 +112,14 @@ class Twofactor extends User $user = $this->getUser(); $this->checkCurrentUser($user); - $otp = new Otp; $values = $this->request->getValues(); - if (! empty($values['code']) && $otp->checkTotp(Base32::decode($user['twofactor_secret']), $values['code'])) { - $this->sessionStorage->postAuth['validated'] = true; + $provider = $this->authenticationManager->getPostAuthenticationProvider(); + $provider->setCode(empty($values['code']) ? '' : $values['code']); + $provider->setSecret($user['twofactor_secret']); + + if ($provider->authenticate()) { + $this->userSession->validatePostAuthentication(); $this->flash->success(t('The two factor authentication code is valid.')); $this->response->redirect($this->helper->url->to('app', 'index')); } else { diff --git a/app/Controller/User.php b/app/Controller/User.php index 23e19828..aa548647 100644 --- a/app/Controller/User.php +++ b/app/Controller/User.php @@ -3,6 +3,8 @@ namespace Kanboard\Controller; use Kanboard\Notification\Mail as MailNotification; +use Kanboard\Model\Project as ProjectModel; +use Kanboard\Core\Security\Role; /** * User controller @@ -24,7 +26,7 @@ class User extends Base { $content = $this->template->render($template, $params); $params['user_content_for_layout'] = $content; - $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId()); + $params['board_selector'] = $this->projectUserRole->getProjectsByUser($this->userSession->getId()); if (isset($params['user'])) { $params['title'] = ($params['user']['name'] ?: $params['user']['username']).' (#'.$params['user']['id'].')'; @@ -49,7 +51,7 @@ class User extends Base $this->response->html( $this->template->layout('user/index', array( - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), 'title' => t('Users').' ('.$paginator->getTotal().')', 'paginator' => $paginator, ))); @@ -67,10 +69,11 @@ class User extends Base $this->response->html($this->template->layout($is_remote ? 'user/create_remote' : 'user/create_local', array( 'timezones' => $this->config->getTimezones(true), 'languages' => $this->config->getLanguages(true), - 'board_selector' => $this->projectPermission->getAllowedProjects($this->userSession->getId()), + 'roles' => $this->role->getApplicationRoles(), + 'board_selector' => $this->projectUserRole->getProjectsByUser($this->userSession->getId()), 'projects' => $this->project->getList(), 'errors' => $errors, - 'values' => $values, + 'values' => $values + array('role' => Role::APP_USER), 'title' => t('New user') ))); } @@ -92,7 +95,7 @@ class User extends Base $user_id = $this->user->create($values); if ($user_id !== false) { - $this->projectPermission->addMember($project_id, $user_id); + $this->projectUserRole->addUser($project_id, $user_id, Role::PROJECT_MEMBER); if (! empty($values['notifications_enabled'])) { $this->userNotificationType->saveSelectedTypes($user_id, array(MailNotification::TYPE)); @@ -170,7 +173,7 @@ class User extends Base { $user = $this->getUser(); $this->response->html($this->layout('user/sessions', array( - 'sessions' => $this->authentication->backend('rememberMe')->getAll($user['id']), + 'sessions' => $this->rememberMeSession->getAll($user['id']), 'user' => $user, ))); } @@ -184,8 +187,8 @@ class User extends Base { $this->checkCSRFParam(); $user = $this->getUser(); - $this->authentication->backend('rememberMe')->remove($this->request->getIntegerParam('id')); - $this->response->redirect($this->helper->url->to('user', 'session', array('user_id' => $user['id']))); + $this->rememberMeSession->remove($this->request->getIntegerParam('id')); + $this->response->redirect($this->helper->url->to('user', 'sessions', array('user_id' => $user['id']))); } /** @@ -205,7 +208,7 @@ class User extends Base } $this->response->html($this->layout('user/notifications', array( - 'projects' => $this->projectPermission->getMemberProjects($user['id']), + 'projects' => $this->projectUserRole->getProjectsByUser($user['id'], array(ProjectModel::ACTIVE)), 'notifications' => $this->userNotification->readSettings($user['id']), 'types' => $this->userNotificationType->getTypes(), 'filters' => $this->userNotificationFilter->getFilters(), @@ -326,16 +329,9 @@ class User extends Base if ($this->request->isPost()) { $values = $this->request->getValues(); - if ($this->userSession->isAdmin()) { - $values += array('is_admin' => 0, 'is_project_admin' => 0); - } else { - // Regular users can't be admin - if (isset($values['is_admin'])) { - unset($values['is_admin']); - } - - if (isset($values['is_project_admin'])) { - unset($values['is_project_admin']); + if (! $this->userSession->isAdmin()) { + if (isset($values['role'])) { + unset($values['role']); } } @@ -358,6 +354,7 @@ class User extends Base 'user' => $user, 'timezones' => $this->config->getTimezones(true), 'languages' => $this->config->getLanguages(true), + 'roles' => $this->role->getApplicationRoles(), ))); } diff --git a/app/Controller/UserHelper.php b/app/Controller/UserHelper.php new file mode 100644 index 00000000..f164d0a6 --- /dev/null +++ b/app/Controller/UserHelper.php @@ -0,0 +1,24 @@ +request->getStringParam('term'); + $users = $this->userFilterAutoCompleteFormatter->create($search)->filterByUsernameOrByName()->format(); + $this->response->json($users); + } +} diff --git a/app/Core/Base.php b/app/Core/Base.php index d3171024..2d00e52a 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -5,29 +5,43 @@ namespace Kanboard\Core; use Pimple\Container; /** - * Base class + * Base Class * * @package core * @author Frederic Guillot * - * @property \Kanboard\Core\Session\SessionManager $sessionManager - * @property \Kanboard\Core\Session\SessionStorage $sessionStorage - * @property \Kanboard\Core\Session\FlashMessage $flash - * @property \Kanboard\Core\Helper $helper - * @property \Kanboard\Core\Mail\Client $emailClient - * @property \Kanboard\Core\Paginator $paginator + * @property \Kanboard\Core\Cache\MemoryCache $memoryCache + * @property \Kanboard\Core\Group\GroupManager $groupManager * @property \Kanboard\Core\Http\Client $httpClient + * @property \Kanboard\Core\Http\OAuth2 $oauth + * @property \Kanboard\Core\Http\RememberMeCookie $rememberMeCookie * @property \Kanboard\Core\Http\Request $request - * @property \Kanboard\Core\Http\Router $router * @property \Kanboard\Core\Http\Response $response - * @property \Kanboard\Core\Template $template - * @property \Kanboard\Core\OAuth2 $oauth - * @property \Kanboard\Core\Lexer $lexer + * @property \Kanboard\Core\Http\Router $router + * @property \Kanboard\Core\Mail\Client $emailClient * @property \Kanboard\Core\ObjectStorage\ObjectStorageInterface $objectStorage - * @property \Kanboard\Core\Cache\Cache $memoryCache * @property \Kanboard\Core\Plugin\Hook $hook * @property \Kanboard\Core\Plugin\Loader $pluginLoader + * @property \Kanboard\Core\Security\AccessMap $projectAccessMap + * @property \Kanboard\Core\Security\AuthenticationManager $authenticationManager + * @property \Kanboard\Core\Security\AccessMap $applicationAccessMap + * @property \Kanboard\Core\Security\AccessMap $projectAccessMap + * @property \Kanboard\Core\Security\Authorization $applicationAuthorization + * @property \Kanboard\Core\Security\Authorization $projectAuthorization + * @property \Kanboard\Core\Security\Role $role * @property \Kanboard\Core\Security\Token $token + * @property \Kanboard\Core\Session\FlashMessage $flash + * @property \Kanboard\Core\Session\SessionManager $sessionManager + * @property \Kanboard\Core\Session\SessionStorage $sessionStorage + * @property \Kanboard\Core\User\GroupSync $groupSync + * @property \Kanboard\Core\User\UserProfile $userProfile + * @property \Kanboard\Core\User\UserSync $userSync + * @property \Kanboard\Core\User\UserSession $userSession + * @property \Kanboard\Core\DateParser $dateParser + * @property \Kanboard\Core\Helper $helper + * @property \Kanboard\Core\Lexer $lexer + * @property \Kanboard\Core\Paginator $paginator + * @property \Kanboard\Core\Template $template * @property \Kanboard\Integration\BitbucketWebhook $bitbucketWebhook * @property \Kanboard\Integration\GithubWebhook $githubWebhook * @property \Kanboard\Integration\GitlabWebhook $gitlabWebhook @@ -36,7 +50,8 @@ use Pimple\Container; * @property \Kanboard\Formatter\TaskFilterAutoCompleteFormatter $taskFilterAutoCompleteFormatter * @property \Kanboard\Formatter\TaskFilterCalendarFormatter $taskFilterCalendarFormatter * @property \Kanboard\Formatter\TaskFilterICalendarFormatter $taskFilterICalendarFormatter - * @property \Kanboard\Model\Acl $acl + * @property \Kanboard\Formatter\UserFilterAutoCompleteFormatter $userFilterAutoCompleteFormatter + * @property \Kanboard\Formatter\GroupAutoCompleteFormatter $groupAutoCompleteFormatter * @property \Kanboard\Model\Action $action * @property \Kanboard\Model\Authentication $authentication * @property \Kanboard\Model\Board $board @@ -46,8 +61,9 @@ use Pimple\Container; * @property \Kanboard\Model\Config $config * @property \Kanboard\Model\Currency $currency * @property \Kanboard\Model\CustomFilter $customFilter - * @property \Kanboard\Model\DateParser $dateParser * @property \Kanboard\Model\File $file + * @property \Kanboard\Model\Group $group + * @property \Kanboard\Model\GroupMember $groupMember * @property \Kanboard\Model\LastLogin $lastLogin * @property \Kanboard\Model\Link $link * @property \Kanboard\Model\Notification $notification @@ -60,8 +76,11 @@ use Pimple\Container; * @property \Kanboard\Model\ProjectDailyStats $projectDailyStats * @property \Kanboard\Model\ProjectMetadata $projectMetadata * @property \Kanboard\Model\ProjectPermission $projectPermission + * @property \Kanboard\Model\ProjectUserRole $projectUserRole + * @property \Kanboard\Model\ProjectGroupRole $projectGroupRole * @property \Kanboard\Model\ProjectNotification $projectNotification * @property \Kanboard\Model\ProjectNotificationType $projectNotificationType + * @property \Kanboard\Model\RememberMeSession $rememberMeSession * @property \Kanboard\Model\Subtask $subtask * @property \Kanboard\Model\SubtaskExport $subtaskExport * @property \Kanboard\Model\SubtaskTimeTracking $subtaskTimeTracking @@ -84,16 +103,17 @@ use Pimple\Container; * @property \Kanboard\Model\Transition $transition * @property \Kanboard\Model\User $user * @property \Kanboard\Model\UserImport $userImport + * @property \Kanboard\Model\UserLocking $userLocking * @property \Kanboard\Model\UserNotification $userNotification * @property \Kanboard\Model\UserNotificationType $userNotificationType * @property \Kanboard\Model\UserNotificationFilter $userNotificationFilter * @property \Kanboard\Model\UserUnreadNotification $userUnreadNotification - * @property \Kanboard\Model\UserSession $userSession * @property \Kanboard\Model\UserMetadata $userMetadata * @property \Kanboard\Model\Webhook $webhook * @property \Psr\Log\LoggerInterface $logger * @property \League\HTMLToMarkdown\HtmlConverter $htmlConverter * @property \PicoDb\Database $db + * @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher */ abstract class Base { diff --git a/app/Core/Cache/MemoryCache.php b/app/Core/Cache/MemoryCache.php index c4fb7ca4..39e3947b 100644 --- a/app/Core/Cache/MemoryCache.php +++ b/app/Core/Cache/MemoryCache.php @@ -23,7 +23,7 @@ class MemoryCache extends Base implements CacheInterface * * @access public * @param string $key - * @param string $value + * @param mixed $value */ public function set($key, $value) { diff --git a/app/Core/Group/GroupBackendProviderInterface.php b/app/Core/Group/GroupBackendProviderInterface.php new file mode 100644 index 00000000..74c5cb03 --- /dev/null +++ b/app/Core/Group/GroupBackendProviderInterface.php @@ -0,0 +1,21 @@ +providers[] = $provider; + return $this; + } + + /** + * Find a group from a search query + * + * @access public + * @param string $input + * @return GroupProviderInterface[] + */ + public function find($input) + { + $groups = array(); + + foreach ($this->providers as $provider) { + $groups = array_merge($groups, $provider->find($input)); + } + + return $this->removeDuplicates($groups); + } + + /** + * Remove duplicated groups + * + * @access private + * @param array $groups + * @return GroupProviderInterface[] + */ + private function removeDuplicates(array $groups) + { + $result = array(); + + foreach ($groups as $group) { + if (! isset($result[$group->getName()])) { + $result[$group->getName()] = $group; + } + } + + return $result; + } +} diff --git a/app/Core/Group/GroupProviderInterface.php b/app/Core/Group/GroupProviderInterface.php new file mode 100644 index 00000000..4c7c16ec --- /dev/null +++ b/app/Core/Group/GroupProviderInterface.php @@ -0,0 +1,40 @@ +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 @@ + $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 index 9f89a6e2..c626f5b2 100644 --- a/app/Core/Http/Request.php +++ b/app/Core/Http/Request.php @@ -2,6 +2,7 @@ namespace Kanboard\Core\Http; +use Pimple\Container; use Kanboard\Core\Base; /** @@ -13,7 +14,35 @@ use Kanboard\Core\Base; class Request extends Base { /** - * Get URL string parameter + * 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; + } + + /** + * Get query string string parameter * * @access public * @param string $name Parameter name @@ -22,11 +51,11 @@ class Request extends Base */ public function getStringParam($name, $default_value = '') { - return isset($_GET[$name]) ? $_GET[$name] : $default_value; + return isset($this->get[$name]) ? $this->get[$name] : $default_value; } /** - * Get URL integer parameter + * Get query string integer parameter * * @access public * @param string $name Parameter name @@ -35,7 +64,7 @@ class Request extends Base */ public function getIntegerParam($name, $default_value = 0) { - return isset($_GET[$name]) && ctype_digit($_GET[$name]) ? (int) $_GET[$name] : $default_value; + return isset($this->get[$name]) && ctype_digit($this->get[$name]) ? (int) $this->get[$name] : $default_value; } /** @@ -59,9 +88,9 @@ class Request extends Base */ public function getValues() { - if (! empty($_POST) && ! empty($_POST['csrf_token']) && $this->token->validateCSRFToken($_POST['csrf_token'])) { - unset($_POST['csrf_token']); - return $_POST; + 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(); @@ -98,8 +127,8 @@ class Request extends Base */ public function getFileContent($name) { - if (isset($_FILES[$name])) { - return file_get_contents($_FILES[$name]['tmp_name']); + if (isset($this->files[$name]['tmp_name'])) { + return file_get_contents($this->files[$name]['tmp_name']); } return ''; @@ -114,7 +143,7 @@ class Request extends Base */ public function getFilePath($name) { - return isset($_FILES[$name]['tmp_name']) ? $_FILES[$name]['tmp_name'] : ''; + return isset($this->files[$name]['tmp_name']) ? $this->files[$name]['tmp_name'] : ''; } /** @@ -125,7 +154,7 @@ class Request extends Base */ public function isPost() { - return isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST'; + return isset($this->server['REQUEST_METHOD']) && $this->server['REQUEST_METHOD'] === 'POST'; } /** @@ -144,13 +173,24 @@ class Request extends Base * * 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() + 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($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== '' && $_SERVER['HTTPS'] !== 'off'; + return isset($this->cookies[$name]) ? $this->cookies[$name] : ''; } /** @@ -163,7 +203,18 @@ class Request extends Base public function getHeader($name) { $name = 'HTTP_'.str_replace('-', '_', strtoupper($name)); - return isset($_SERVER[$name]) ? $_SERVER[$name] : ''; + return isset($this->server[$name]) ? $this->server[$name] : ''; + } + + /** + * Get remote user + * + * @access public + * @return string + */ + public function getRemoteUser() + { + return isset($this->server[REVERSE_PROXY_USER_HEADER]) ? $this->server[REVERSE_PROXY_USER_HEADER] : ''; } /** @@ -174,41 +225,38 @@ class Request extends Base */ public function getQueryString() { - return isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : ''; + return isset($this->server['QUERY_STRING']) ? $this->server['QUERY_STRING'] : ''; } /** - * Returns uri + * Return URI * * @access public * @return string */ public function getUri() { - return isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''; + return isset($this->server['REQUEST_URI']) ? $this->server['REQUEST_URI'] : ''; } /** * Get the user agent * - * @static * @access public * @return string */ - public static function getUserAgent() + public function getUserAgent() { - return empty($_SERVER['HTTP_USER_AGENT']) ? t('Unknown') : $_SERVER['HTTP_USER_AGENT']; + return empty($this->server['HTTP_USER_AGENT']) ? t('Unknown') : $this->server['HTTP_USER_AGENT']; } /** - * Get the real IP address of the user + * Get the 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) + public function getIpAddress() { $keys = array( 'HTTP_CLIENT_IP', @@ -221,23 +269,24 @@ class Request extends Base ); 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; - } + if (! empty($this->server[$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 isset($this->server['REQUEST_TIME_FLOAT']) ? $this->server['REQUEST_TIME_FLOAT'] : 0; + } } diff --git a/app/Core/Http/Response.php b/app/Core/Http/Response.php index c5a5d3cc..fc214010 100644 --- a/app/Core/Http/Response.php +++ b/app/Core/Http/Response.php @@ -257,7 +257,7 @@ class Response extends Base */ public function hsts() { - if (Request::isHTTPS()) { + if ($this->request->isHTTPS()) { header('Strict-Transport-Security: max-age=31536000'); } } diff --git a/app/Core/Ldap/Client.php b/app/Core/Ldap/Client.php index a523428c..5d481cd3 100644 --- a/app/Core/Ldap/Client.php +++ b/app/Core/Ldap/Client.php @@ -2,6 +2,8 @@ namespace Kanboard\Core\Ldap; +use LogicException; + /** * LDAP Client * @@ -10,17 +12,61 @@ namespace Kanboard\Core\Ldap; */ class Client { + /** + * LDAP resource + * + * @access private + * @var resource + */ + private $ldap; + + /** + * Establish LDAP connection + * + * @static + * @access public + * @param string $username + * @param string $password + * @return Client + */ + public static function connect($username = null, $password = null) + { + $client = new self; + $client->open($client->getLdapServer()); + $username = $username ?: $client->getLdapUsername(); + $password = $password ?: $client->getLdapPassword(); + + if (empty($username) && empty($password)) { + $client->useAnonymousAuthentication(); + } else { + $client->authenticate($username, $password); + } + + return $client; + } + /** * Get server connection * * @access public + * @return resource + */ + public function getConnection() + { + return $this->ldap; + } + + /** + * Establish server connection + * + * @access public * @param string $server LDAP server hostname or IP * @param integer $port LDAP port * @param boolean $tls Start TLS * @param boolean $verify Skip SSL certificate verification - * @return resource + * @return Client */ - public function getConnection($server, $port = LDAP_PORT, $tls = LDAP_START_TLS, $verify = LDAP_SSL_VERIFY) + public function open($server, $port = LDAP_PORT, $tls = LDAP_START_TLS, $verify = LDAP_SSL_VERIFY) { if (! function_exists('ldap_connect')) { throw new ClientException('LDAP: The PHP LDAP extension is required'); @@ -30,34 +76,33 @@ class Client putenv('LDAPTLS_REQCERT=never'); } - $ldap = ldap_connect($server, $port); + $this->ldap = ldap_connect($server, $port); - if ($ldap === false) { + if ($this->ldap === false) { throw new ClientException('LDAP: Unable to connect to the LDAP server'); } - ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3); - ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0); - ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 1); - ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 1); + ldap_set_option($this->ldap, LDAP_OPT_PROTOCOL_VERSION, 3); + ldap_set_option($this->ldap, LDAP_OPT_REFERRALS, 0); + ldap_set_option($this->ldap, LDAP_OPT_NETWORK_TIMEOUT, 1); + ldap_set_option($this->ldap, LDAP_OPT_TIMELIMIT, 1); - if ($tls && ! @ldap_start_tls($ldap)) { + if ($tls && ! @ldap_start_tls($this->ldap)) { throw new ClientException('LDAP: Unable to start TLS'); } - return $ldap; + return $this; } /** * Anonymous authentication * * @access public - * @param resource $ldap * @return boolean */ - public function useAnonymousAuthentication($ldap) + public function useAnonymousAuthentication() { - if (! ldap_bind($ldap)) { + if (! @ldap_bind($this->ldap)) { throw new ClientException('Unable to perform anonymous binding'); } @@ -68,17 +113,53 @@ class Client * Authentication with username/password * * @access public - * @param resource $ldap - * @param string $username - * @param string $password + * @param string $bind_rdn + * @param string $bind_password * @return boolean */ - public function authenticate($ldap, $username, $password) + public function authenticate($bind_rdn, $bind_password) { - if (! ldap_bind($ldap, $username, $password)) { - throw new ClientException('Unable to perform anonymous binding'); + if (! @ldap_bind($this->ldap, $bind_rdn, $bind_password)) { + throw new ClientException('LDAP authentication failure for "'.$bind_rdn.'"'); } return true; } + + /** + * Get LDAP server name + * + * @access public + * @return string + */ + public function getLdapServer() + { + if (! LDAP_SERVER) { + throw new LogicException('LDAP server not configured, check the parameter LDAP_SERVER'); + } + + return LDAP_SERVER; + } + + /** + * Get LDAP username (proxy auth) + * + * @access public + * @return string + */ + public function getLdapUsername() + { + return LDAP_USERNAME; + } + + /** + * Get LDAP password (proxy auth) + * + * @access public + * @return string + */ + public function getLdapPassword() + { + return LDAP_PASSWORD; + } } diff --git a/app/Core/Ldap/Entries.php b/app/Core/Ldap/Entries.php new file mode 100644 index 00000000..b0f78fa4 --- /dev/null +++ b/app/Core/Ldap/Entries.php @@ -0,0 +1,63 @@ +entries = $entries; + } + + /** + * Get all entries + * + * @access public + * @return []Entry + */ + public function getAll() + { + $entities = array(); + + if (! isset($this->entries['count'])) { + return $entities; + } + + for ($i = 0; $i < $this->entries['count']; $i++) { + $entities[] = new Entry($this->entries[$i]); + } + + return $entities; + } + + /** + * Get first entry + * + * @access public + * @return Entry + */ + public function getFirstEntry() + { + return new Entry(isset($this->entries[0]) ? $this->entries[0] : array()); + } +} diff --git a/app/Core/Ldap/Entry.php b/app/Core/Ldap/Entry.php new file mode 100644 index 00000000..e67dd625 --- /dev/null +++ b/app/Core/Ldap/Entry.php @@ -0,0 +1,91 @@ +entry = $entry; + } + + /** + * Get all attribute values + * + * @access public + * @param string $attribute + * @return string[] + */ + public function getAll($attribute) + { + $attributes = array(); + + if (! isset($this->entry[$attribute]['count'])) { + return $attributes; + } + + for ($i = 0; $i < $this->entry[$attribute]['count']; $i++) { + $attributes[] = $this->entry[$attribute][$i]; + } + + return $attributes; + } + + /** + * Get first attribute value + * + * @access public + * @param string $attribute + * @param string $default + * @return string + */ + public function getFirstValue($attribute, $default = '') + { + return isset($this->entry[$attribute][0]) ? $this->entry[$attribute][0] : $default; + } + + /** + * Get entry distinguished name + * + * @access public + * @return string + */ + public function getDn() + { + return isset($this->entry['dn']) ? $this->entry['dn'] : ''; + } + + /** + * Return true if the given value exists in attribute list + * + * @access public + * @param string $attribute + * @param string $value + * @return boolean + */ + public function hasValue($attribute, $value) + { + $attributes = $this->getAll($attribute); + return in_array($value, $attributes); + } +} diff --git a/app/Core/Ldap/Group.php b/app/Core/Ldap/Group.php new file mode 100644 index 00000000..e11e8ecd --- /dev/null +++ b/app/Core/Ldap/Group.php @@ -0,0 +1,130 @@ +query = $query; + } + + /** + * Get groups + * + * @static + * @access public + * @param Client $client + * @param string $query + * @return array + */ + public static function getGroups(Client $client, $query) + { + $self = new self(new Query($client)); + return $self->find($query); + } + + /** + * Find groups + * + * @access public + * @param string $query + * @return array + */ + public function find($query) + { + $this->query->execute($this->getBasDn(), $query, $this->getAttributes()); + $groups = array(); + + if ($this->query->hasResult()) { + $groups = $this->build(); + } + + return $groups; + } + + /** + * Build groups list + * + * @access protected + * @return array + */ + protected function build() + { + $groups = array(); + + foreach ($this->query->getEntries()->getAll() as $entry) { + $groups[] = new LdapGroupProvider($entry->getDn(), $entry->getFirstValue($this->getAttributeName())); + } + + return $groups; + } + + /** + * Ge the list of attributes to fetch when reading the LDAP group entry + * + * Must returns array with index that start at 0 otherwise ldap_search returns a warning "Array initialization wrong" + * + * @access public + * @return array + */ + public function getAttributes() + { + return array_values(array_filter(array( + $this->getAttributeName(), + ))); + } + + /** + * Get LDAP group name attribute + * + * @access public + * @return string + */ + public function getAttributeName() + { + if (! LDAP_GROUP_ATTRIBUTE_NAME) { + throw new LogicException('LDAP full name attribute empty, check the parameter LDAP_GROUP_ATTRIBUTE_NAME'); + } + + return LDAP_GROUP_ATTRIBUTE_NAME; + } + + /** + * Get LDAP group base DN + * + * @access public + * @return string + */ + public function getBasDn() + { + if (! LDAP_GROUP_BASE_DN) { + throw new LogicException('LDAP group base DN empty, check the parameter LDAP_GROUP_BASE_DN'); + } + + return LDAP_GROUP_BASE_DN; + } +} diff --git a/app/Core/Ldap/Query.php b/app/Core/Ldap/Query.php index 1c34fa10..6ca4bc96 100644 --- a/app/Core/Ldap/Query.php +++ b/app/Core/Ldap/Query.php @@ -10,6 +10,14 @@ namespace Kanboard\Core\Ldap; */ class Query { + /** + * LDAP client + * + * @access private + * @var Client + */ + private $client = null; + /** * Query result * @@ -22,31 +30,30 @@ class Query * Constructor * * @access public - * @param array $entries + * @param Client $client */ - public function __construct(array $entries = array()) + public function __construct(Client $client) { - $this->entries = $entries; + $this->client = $client; } /** * Execute query * * @access public - * @param resource $ldap * @param string $baseDn * @param string $filter * @param array $attributes * @return Query */ - public function execute($ldap, $baseDn, $filter, array $attributes) + public function execute($baseDn, $filter, array $attributes) { - $sr = ldap_search($ldap, $baseDn, $filter, $attributes); + $sr = ldap_search($this->client->getConnection(), $baseDn, $filter, $attributes); if ($sr === false) { return $this; } - $entries = ldap_get_entries($ldap, $sr); + $entries = ldap_get_entries($this->client->getConnection(), $sr); if ($entries === false || count($entries) === 0 || $entries['count'] == 0) { return $this; } @@ -68,28 +75,13 @@ class Query } /** - * Return subset of entries - * - * @access public - * @param string $key - * @param mixed $default - * @return array - */ - public function getAttribute($key, $default = null) - { - return isset($this->entries[0][$key]) ? $this->entries[0][$key] : $default; - } - - /** - * Return one entry from a list of entries + * Get LDAP Entries * * @access public - * @param string $key Key - * @param string $default Default value if key not set in entry - * @return string + * @return Entities */ - public function getAttributeValue($key, $default = '') + public function getEntries() { - return isset($this->entries[0][$key][0]) ? $this->entries[0][$key][0] : $default; + return new Entries($this->entries); } } diff --git a/app/Core/Ldap/User.php b/app/Core/Ldap/User.php index e44a4dda..ab8d7296 100644 --- a/app/Core/Ldap/User.php +++ b/app/Core/Ldap/User.php @@ -2,8 +2,12 @@ namespace Kanboard\Core\Ldap; +use LogicException; +use Kanboard\Core\Security\Role; +use Kanboard\User\LdapUserProvider; + /** - * LDAP User + * LDAP User Finder * * @package ldap * @author Frederic Guillot @@ -24,72 +28,70 @@ class User * @access public * @param Query $query */ - public function __construct(Query $query = null) + public function __construct(Query $query) { - $this->query = $query ?: new Query; + $this->query = $query; } /** - * Get user profile + * Get user profile (helper) * + * @static * @access public - * @param resource $ldap - * @param string $baseDn + * @param Client $client * @param string $query * @return array */ - public function getProfile($ldap, $baseDn, $query) + public static function getUser(Client $client, $query) { - $this->query->execute($ldap, $baseDn, $query, $this->getAttributes()); - $profile = array(); - - if ($this->query->hasResult()) { - $profile = $this->prepareProfile(); - } - - return $profile; + $self = new self(new Query($client)); + return $self->find($query); } /** - * Build user profile + * Find user * - * @access private - * @return boolean|array + * @access public + * @param string $query + * @return null|LdapUserProvider */ - private function prepareProfile() + public function find($query) { - return array( - 'ldap_id' => $this->query->getAttribute('dn', ''), - 'username' => $this->query->getAttributeValue($this->getAttributeUsername()), - 'name' => $this->query->getAttributeValue($this->getAttributeName()), - 'email' => $this->query->getAttributeValue($this->getAttributeEmail()), - 'is_admin' => (int) $this->isMemberOf($this->query->getAttribute($this->getAttributeGroup(), array()), $this->getGroupAdminDn()), - 'is_project_admin' => (int) $this->isMemberOf($this->query->getAttribute($this->getAttributeGroup(), array()), $this->getGroupProjectAdminDn()), - 'is_ldap_user' => 1, - ); + $this->query->execute($this->getBasDn(), $query, $this->getAttributes()); + $user = null; + + if ($this->query->hasResult()) { + $user = $this->build(); + } + + return $user; } /** - * Check group membership + * Build user profile * - * @access public - * @param array $group_entries - * @param string $group_dn - * @return boolean + * @access protected + * @return LdapUserProvider */ - public function isMemberOf(array $group_entries, $group_dn) + protected function build() { - if (! isset($group_entries['count']) || empty($group_dn)) { - return false; - } + $entry = $this->query->getEntries()->getFirstEntry(); + $role = Role::APP_USER; - for ($i = 0; $i < $group_entries['count']; $i++) { - if ($group_entries[$i] === $group_dn) { - return true; - } + if ($entry->hasValue($this->getAttributeGroup(), $this->getGroupAdminDn())) { + $role = Role::APP_ADMIN; + } elseif ($entry->hasValue($this->getAttributeGroup(), $this->getGroupManagerDn())) { + $role = Role::APP_MANAGER; } - return false; + return new LdapUserProvider( + $entry->getDn(), + $entry->getFirstValue($this->getAttributeUsername()), + $entry->getFirstValue($this->getAttributeName()), + $entry->getFirstValue($this->getAttributeEmail()), + $role, + $entry->getAll($this->getAttributeGroup()) + ); } /** @@ -118,29 +120,41 @@ class User */ public function getAttributeUsername() { - return LDAP_ACCOUNT_ID; + if (! LDAP_USER_ATTRIBUTE_USERNAME) { + throw new LogicException('LDAP username attribute empty, check the parameter LDAP_USER_ATTRIBUTE_USERNAME'); + } + + return LDAP_USER_ATTRIBUTE_USERNAME; } /** - * Get LDAP account email attribute + * Get LDAP user name attribute * * @access public * @return string */ - public function getAttributeEmail() + public function getAttributeName() { - return LDAP_ACCOUNT_EMAIL; + if (! LDAP_USER_ATTRIBUTE_FULLNAME) { + throw new LogicException('LDAP full name attribute empty, check the parameter LDAP_USER_ATTRIBUTE_FULLNAME'); + } + + return LDAP_USER_ATTRIBUTE_FULLNAME; } /** - * Get LDAP account name attribute + * Get LDAP account email attribute * * @access public * @return string */ - public function getAttributeName() + public function getAttributeEmail() { - return LDAP_ACCOUNT_FULLNAME; + if (! LDAP_USER_ATTRIBUTE_EMAIL) { + throw new LogicException('LDAP email attribute empty, check the parameter LDAP_USER_ATTRIBUTE_EMAIL'); + } + + return LDAP_USER_ATTRIBUTE_EMAIL; } /** @@ -151,7 +165,7 @@ class User */ public function getAttributeGroup() { - return LDAP_ACCOUNT_MEMBEROF; + return LDAP_USER_ATTRIBUTE_GROUPS; } /** @@ -166,13 +180,28 @@ class User } /** - * Get LDAP project admin group DN + * Get LDAP application manager group DN * * @access public * @return string */ - public function getGroupProjectAdminDn() + public function getGroupManagerDn() { - return LDAP_GROUP_PROJECT_ADMIN_DN; + return LDAP_GROUP_MANAGER_DN; + } + + /** + * Get LDAP user base DN + * + * @access public + * @return string + */ + public function getBasDn() + { + if (! LDAP_USER_BASE_DN) { + throw new LogicException('LDAP user base DN empty, check the parameter LDAP_USER_BASE_DN'); + } + + return LDAP_USER_BASE_DN; } } diff --git a/app/Core/OAuth2.php b/app/Core/OAuth2.php deleted file mode 100644 index a5bbba1a..00000000 --- a/app/Core/OAuth2.php +++ /dev/null @@ -1,119 +0,0 @@ -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/Security/AccessMap.php b/app/Core/Security/AccessMap.php index 10a29e1f..02a4ca45 100644 --- a/app/Core/Security/AccessMap.php +++ b/app/Core/Security/AccessMap.php @@ -18,6 +18,14 @@ class AccessMap */ private $defaultRole = ''; + /** + * Role hierarchy + * + * @access private + * @var array + */ + private $hierarchy = array(); + /** * Access map * @@ -39,16 +47,77 @@ class AccessMap return $this; } + /** + * Define role hierarchy + * + * @access public + * @param string $role + * @param array $subroles + * @return Acl + */ + public function setRoleHierarchy($role, array $subroles) + { + foreach ($subroles as $subrole) { + if (isset($this->hierarchy[$subrole])) { + $this->hierarchy[$subrole][] = $role; + } else { + $this->hierarchy[$subrole] = array($role); + } + } + + return $this; + } + + /** + * Get computed role hierarchy + * + * @access public + * @param string $role + * @return array + */ + public function getRoleHierarchy($role) + { + $roles = array($role); + + if (isset($this->hierarchy[$role])) { + $roles = array_merge($roles, $this->hierarchy[$role]); + } + + return $roles; + } + /** * Add new access rules * * @access public + * @param string $controller Controller class name + * @param mixed $methods List of method name or just one method + * @param string $role Lowest role required + * @return Acl + */ + public function add($controller, $methods, $role) + { + if (is_array($methods)) { + foreach ($methods as $method) { + $this->addRule($controller, $method, $role); + } + } else { + $this->addRule($controller, $methods, $role); + } + + return $this; + } + + /** + * Add new access rule + * + * @access private * @param string $controller * @param string $method - * @param array $roles + * @param string $role * @return Acl */ - public function add($controller, $method, array $roles) + private function addRule($controller, $method, $role) { $controller = strtolower($controller); $method = strtolower($method); @@ -57,11 +126,7 @@ class AccessMap $this->map[$controller] = array(); } - if (! isset($this->map[$controller][$method])) { - $this->map[$controller][$method] = array(); - } - - $this->map[$controller][$method] = $roles; + $this->map[$controller][$method] = $role; return $this; } @@ -79,14 +144,12 @@ class AccessMap $controller = strtolower($controller); $method = strtolower($method); - if (isset($this->map[$controller][$method])) { - return $this->map[$controller][$method]; - } - - if (isset($this->map[$controller]['*'])) { - return $this->map[$controller]['*']; + foreach (array($method, '*') as $key) { + if (isset($this->map[$controller][$key])) { + return $this->getRoleHierarchy($this->map[$controller][$key]); + } } - return array($this->defaultRole); + return $this->getRoleHierarchy($this->defaultRole); } } diff --git a/app/Core/Security/AuthenticationManager.php b/app/Core/Security/AuthenticationManager.php new file mode 100644 index 00000000..cced58c0 --- /dev/null +++ b/app/Core/Security/AuthenticationManager.php @@ -0,0 +1,187 @@ +providers[$provider->getName()] = $provider; + return $this; + } + + /** + * Register a new authentication provider + * + * @access public + * @param string $name + * @return AuthenticationProviderInterface|OAuthAuthenticationProviderInterface|PasswordAuthenticationProviderInterface|PreAuthenticationProviderInterface|OAuthAuthenticationProviderInterface + */ + public function getProvider($name) + { + if (! isset($this->providers[$name])) { + throw new LogicException('Authentication provider not found: '.$name); + } + + return $this->providers[$name]; + } + + /** + * Execute providers that are able to validate the current session + * + * @access public + * @return boolean + */ + public function checkCurrentSession() + { + if ($this->userSession->isLogged() ) { + foreach ($this->filterProviders('SessionCheckProviderInterface') as $provider) { + if (! $provider->isValidSession()) { + unset($this->sessionStorage->user); + $this->preAuthentication(); + return false; + } + } + } + + return true; + } + + /** + * Execute pre-authentication providers + * + * @access public + * @return boolean + */ + public function preAuthentication() + { + foreach ($this->filterProviders('PreAuthenticationProviderInterface') as $provider) { + if ($provider->authenticate() && $this->userProfile->initialize($provider->getUser())) { + $this->dispatcher->dispatch(self::EVENT_SUCCESS, new AuthSuccessEvent($provider->getName())); + return true; + } + } + + return false; + } + + /** + * Execute username/password authentication providers + * + * @access public + * @param string $username + * @param string $password + * @param boolean $fireEvent + * @return boolean + */ + public function passwordAuthentication($username, $password, $fireEvent = true) + { + foreach ($this->filterProviders('PasswordAuthenticationProviderInterface') as $provider) { + $provider->setUsername($username); + $provider->setPassword($password); + + if ($provider->authenticate() && $this->userProfile->initialize($provider->getUser())) { + if ($fireEvent) { + $this->dispatcher->dispatch(self::EVENT_SUCCESS, new AuthSuccessEvent($provider->getName())); + } + + return true; + } + } + + if ($fireEvent) { + $this->dispatcher->dispatch(self::EVENT_FAILURE, new AuthFailureEvent($username)); + } + + return false; + } + + /** + * Perform OAuth2 authentication + * + * @access public + * @param string $name + * @return boolean + */ + public function oauthAuthentication($name) + { + $provider = $this->getProvider($name); + + if ($provider->authenticate() && $this->userProfile->initialize($provider->getUser())) { + $this->dispatcher->dispatch(self::EVENT_SUCCESS, new AuthSuccessEvent($provider->getName())); + return true; + } + + $this->dispatcher->dispatch(self::EVENT_FAILURE, new AuthFailureEvent); + + return false; + } + + /** + * Get the last Post-Authentication provider + * + * @access public + * @return PostAuthenticationProviderInterface + */ + public function getPostAuthenticationProvider() + { + $providers = $this->filterProviders('PostAuthenticationProviderInterface'); + + if (empty($providers)) { + throw new LogicException('You must have at least one Post-Authentication Provider configured'); + } + + return array_pop($providers); + } + + /** + * Filter registered providers by interface type + * + * @access private + * @param string $interface + * @return array + */ + private function filterProviders($interface) + { + $interface = '\Kanboard\Core\Security\\'.$interface; + + return array_filter($this->providers, function(AuthenticationProviderInterface $provider) use ($interface) { + return is_a($provider, $interface); + }); + } +} diff --git a/app/Core/Security/AuthenticationProviderInterface.php b/app/Core/Security/AuthenticationProviderInterface.php new file mode 100644 index 00000000..828e272c --- /dev/null +++ b/app/Core/Security/AuthenticationProviderInterface.php @@ -0,0 +1,28 @@ +acl = $acl; + $this->accessMap = $accessMap; } /** @@ -40,7 +40,7 @@ class Authorization */ public function isAllowed($controller, $method, $role) { - $roles = $this->acl->getRoles($controller, $method); + $roles = $this->accessMap->getRoles($controller, $method); return in_array($role, $roles); } } diff --git a/app/Core/Security/OAuthAuthenticationProviderInterface.php b/app/Core/Security/OAuthAuthenticationProviderInterface.php new file mode 100644 index 00000000..c32339e0 --- /dev/null +++ b/app/Core/Security/OAuthAuthenticationProviderInterface.php @@ -0,0 +1,46 @@ + t('Administrator'), + self::APP_MANAGER => t('Manager'), + self::APP_USER => t('User'), + ); + } + + /** + * Get project roles + * + * @access public + * @return array + */ + public function getProjectRoles() + { + return array( + self::PROJECT_MANAGER => t('Project Manager'), + self::PROJECT_MEMBER => t('Project Member'), + self::PROJECT_VIEWER => t('Project Viewer'), + ); + } + + /** + * Get application roles + * + * @access public + * @param string $role + * @return string + */ + public function getRoleName($role) + { + $roles = $this->getApplicationRoles() + $this->getProjectRoles(); + return isset($roles[$role]) ? $roles[$role] : t('Unknown'); + } } diff --git a/app/Core/Security/SessionCheckProviderInterface.php b/app/Core/Security/SessionCheckProviderInterface.php new file mode 100644 index 00000000..232fe1db --- /dev/null +++ b/app/Core/Security/SessionCheckProviderInterface.php @@ -0,0 +1,20 @@ +container['sessionStorage']->setStorage($_SESSION); + $this->sessionStorage->setStorage($_SESSION); } /** @@ -51,6 +58,8 @@ class SessionManager extends Base */ public function close() { + $this->dispatcher->dispatch(self::EVENT_DESTROY); + // Destroy the session cookie $params = session_get_cookie_params(); @@ -80,7 +89,7 @@ class SessionManager extends Base SESSION_DURATION, $this->helper->url->dir() ?: '/', null, - Request::isHTTPS(), + $this->request->isHTTPS(), true ); diff --git a/app/Core/Session/SessionStorage.php b/app/Core/Session/SessionStorage.php index 703d2fbb..11230793 100644 --- a/app/Core/Session/SessionStorage.php +++ b/app/Core/Session/SessionStorage.php @@ -12,12 +12,13 @@ namespace Kanboard\Core\Session; * @property array $user * @property array $flash * @property array $csrf - * @property array $postAuth + * @property array $postAuthenticationValidated * @property array $filters * @property string $redirectAfterLogin * @property string $captcha * @property string $commentSorting * @property bool $hasSubtaskInProgress + * @property bool $hasRememberMe * @property bool $boardCollapsed */ class SessionStorage diff --git a/app/Core/User/GroupSync.php b/app/Core/User/GroupSync.php new file mode 100644 index 00000000..573acd47 --- /dev/null +++ b/app/Core/User/GroupSync.php @@ -0,0 +1,32 @@ +group->getByExternalId($groupId); + + if (! empty($group) && ! $this->groupMember->isMember($group['id'], $userId)) { + $this->groupMember->addUser($group['id'], $userId); + } + } + } +} diff --git a/app/Core/User/UserProfile.php b/app/Core/User/UserProfile.php new file mode 100644 index 00000000..ccbc7f06 --- /dev/null +++ b/app/Core/User/UserProfile.php @@ -0,0 +1,62 @@ +user->getById($userId); + + $values = UserProperty::filterProperties($profile, UserProperty::getProperties($user)); + $values['id'] = $userId; + + if ($this->user->update($values)) { + $profile = array_merge($profile, $values); + $this->userSession->initialize($profile); + return true; + } + + return false; + } + + /** + * Synchronize user properties with the local database and create the user session + * + * @access public + * @param UserProviderInterface $user + * @return boolean + */ + public function initialize(UserProviderInterface $user) + { + if ($user->getInternalId()) { + $profile = $this->user->getById($user->getInternalId()); + } elseif ($user->getExternalIdColumn() && $user->getExternalId()) { + $profile = $this->userSync->synchronize($user); + $this->groupSync->synchronize($profile['id'], $user->getExternalGroupIds()); + } + + if (! empty($profile)) { + $this->userSession->initialize($profile); + return true; + } + + return false; + } +} diff --git a/app/Core/User/UserProperty.php b/app/Core/User/UserProperty.php new file mode 100644 index 00000000..f8b08a3d --- /dev/null +++ b/app/Core/User/UserProperty.php @@ -0,0 +1,70 @@ + $user->getUsername(), + 'name' => $user->getName(), + 'email' => $user->getEmail(), + 'role' => $user->getRole(), + $user->getExternalIdColumn() => $user->getExternalId(), + ); + + $properties = array_merge($properties, $user->getExtraAttributes()); + + return array_filter($properties, array(__NAMESPACE__.'\UserProperty', 'isNotEmptyValue')); + } + + /** + * Filter user properties compared to existing user profile + * + * @static + * @access public + * @param array $profile + * @param array $properties + * @return array + */ + public static function filterProperties(array $profile, array $properties) + { + $values = array(); + + foreach ($properties as $property => $value) { + if (array_key_exists($property, $profile) && ! self::isNotEmptyValue($profile[$property])) { + $values[$property] = $value; + } + } + + return $values; + } + + /** + * Check if a value is not empty + * + * @static + * @access public + * @param string $value + * @return boolean + */ + public static function isNotEmptyValue($value) + { + return $value !== null && $value !== ''; + } +} diff --git a/app/Core/User/UserProviderInterface.php b/app/Core/User/UserProviderInterface.php new file mode 100644 index 00000000..07e01f42 --- /dev/null +++ b/app/Core/User/UserProviderInterface.php @@ -0,0 +1,103 @@ +sessionStorage->user = $user; + $this->sessionStorage->postAuthenticationValidated = false; + } + + /** + * Get user application role + * + * @access public + * @return string + */ + public function getRole() + { + return $this->sessionStorage->user['role']; + } + + /** + * Return true if the user has validated the 2FA key + * + * @access public + * @return bool + */ + public function isPostAuthenticationValidated() + { + return isset($this->sessionStorage->postAuthenticationValidated) && $this->sessionStorage->postAuthenticationValidated === true; + } + + /** + * Validate 2FA for the current session + * + * @access public + */ + public function validatePostAuthentication() + { + $this->sessionStorage->postAuthenticationValidated = true; + } + + /** + * Return true if the user has 2FA enabled + * + * @access public + * @return bool + */ + public function hasPostAuthentication() + { + return isset($this->sessionStorage->user['twofactor_activated']) && $this->sessionStorage->user['twofactor_activated'] === true; + } + + /** + * Disable 2FA for the current session + * + * @access public + */ + public function disablePostAuthentication() + { + $this->sessionStorage->user['twofactor_activated'] = false; + } + + /** + * Return true if the logged user is admin + * + * @access public + * @return bool + */ + public function isAdmin() + { + return isset($this->sessionStorage->user['role']) && $this->sessionStorage->user['role'] === Role::APP_ADMIN; + } + + /** + * Get the connected user id + * + * @access public + * @return integer + */ + public function getId() + { + return isset($this->sessionStorage->user['id']) ? (int) $this->sessionStorage->user['id'] : 0; + } + + /** + * Get username + * + * @access public + * @return integer + */ + public function getUsername() + { + return isset($this->sessionStorage->user['username']) ? $this->sessionStorage->user['username'] : ''; + } + + /** + * Check is the user is connected + * + * @access public + * @return bool + */ + public function isLogged() + { + return isset($this->sessionStorage->user) && ! empty($this->sessionStorage->user); + } + + /** + * Get project filters from the session + * + * @access public + * @param integer $project_id + * @return string + */ + public function getFilters($project_id) + { + return ! empty($this->sessionStorage->filters[$project_id]) ? $this->sessionStorage->filters[$project_id] : 'status:open'; + } + + /** + * Save project filters in the session + * + * @access public + * @param integer $project_id + * @param string $filters + */ + public function setFilters($project_id, $filters) + { + $this->sessionStorage->filters[$project_id] = $filters; + } + + /** + * Is board collapsed or expanded + * + * @access public + * @param integer $project_id + * @return boolean + */ + public function isBoardCollapsed($project_id) + { + return ! empty($this->sessionStorage->boardCollapsed[$project_id]) ? $this->sessionStorage->boardCollapsed[$project_id] : false; + } + + /** + * Set board display mode + * + * @access public + * @param integer $project_id + * @param boolean $is_collapsed + */ + public function setBoardDisplayMode($project_id, $is_collapsed) + { + $this->sessionStorage->boardCollapsed[$project_id] = $is_collapsed; + } + + /** + * Set comments sorting + * + * @access public + * @param string $order + */ + public function setCommentSorting($order) + { + $this->sessionStorage->commentSorting = $order; + } + + /** + * Get comments sorting direction + * + * @access public + * @return string + */ + public function getCommentSorting() + { + return empty($this->sessionStorage->commentSorting) ? 'ASC' : $this->sessionStorage->commentSorting; + } +} diff --git a/app/Core/User/UserSync.php b/app/Core/User/UserSync.php new file mode 100644 index 00000000..d450a0bd --- /dev/null +++ b/app/Core/User/UserSync.php @@ -0,0 +1,76 @@ +user->getByExternalId($user->getExternalIdColumn(), $user->getExternalId()); + $properties = UserProperty::getProperties($user); + + if (! empty($profile)) { + $profile = $this->updateUser($profile, $properties); + } elseif ($user->isUserCreationAllowed()) { + $profile = $this->createUser($user, $properties); + } + + return $profile; + } + + /** + * Update user profile + * + * @access public + * @param array $profile + * @param array $properties + * @return array + */ + private function updateUser(array $profile, array $properties) + { + $values = UserProperty::filterProperties($profile, $properties); + + if (! empty($values)) { + $values['id'] = $profile['id']; + $result = $this->user->update($values); + return $result ? array_merge($profile, $properties) : $profile; + } + + return $profile; + } + + /** + * Create user + * + * @access public + * @param UserProviderInterface $user + * @param array $properties + * @return array + */ + private function createUser(UserProviderInterface $user, array $properties) + { + $id = $this->user->create($properties); + + if ($id === false) { + $this->logger->error('Unable to create user profile: '.$user->getExternalId()); + return array(); + } + + return $this->user->getById($id); + } +} diff --git a/app/Event/AuthEvent.php b/app/Event/AuthEvent.php deleted file mode 100644 index 7cbced83..00000000 --- a/app/Event/AuthEvent.php +++ /dev/null @@ -1,27 +0,0 @@ -auth_name = $auth_name; - $this->user_id = $user_id; - } - - public function getUserId() - { - return $this->user_id; - } - - public function getAuthType() - { - return $this->auth_name; - } -} diff --git a/app/Event/AuthFailureEvent.php b/app/Event/AuthFailureEvent.php new file mode 100644 index 00000000..225ac04a --- /dev/null +++ b/app/Event/AuthFailureEvent.php @@ -0,0 +1,44 @@ +username = $username; + } + + /** + * Get username + * + * @access public + * @return string + */ + public function getUsername() + { + return $this->username; + } +} diff --git a/app/Event/AuthSuccessEvent.php b/app/Event/AuthSuccessEvent.php new file mode 100644 index 00000000..38323e82 --- /dev/null +++ b/app/Event/AuthSuccessEvent.php @@ -0,0 +1,43 @@ +authType = $authType; + } + + /** + * Get authentication type + * + * @return string + */ + public function getAuthType() + { + return $this->authType; + } +} diff --git a/app/Formatter/GroupAutoCompleteFormatter.php b/app/Formatter/GroupAutoCompleteFormatter.php new file mode 100644 index 00000000..7023e367 --- /dev/null +++ b/app/Formatter/GroupAutoCompleteFormatter.php @@ -0,0 +1,55 @@ +groups = $groups; + return $this; + } + + /** + * Format groups for the ajax autocompletion + * + * @access public + * @return array + */ + public function format() + { + $result = array(); + + foreach ($this->groups as $group) { + $result[] = array( + 'id' => $group->getInternalId(), + 'external_id' => $group->getExternalId(), + 'value' => $group->getName(), + 'label' => $group->getName(), + ); + } + + return $result; + } +} diff --git a/app/Formatter/ProjectGanttFormatter.php b/app/Formatter/ProjectGanttFormatter.php index 17496088..4f73e217 100644 --- a/app/Formatter/ProjectGanttFormatter.php +++ b/app/Formatter/ProjectGanttFormatter.php @@ -79,7 +79,7 @@ class ProjectGanttFormatter extends Project implements FormatterInterface 'gantt_link' => $this->helper->url->href('gantt', 'project', array('project_id' => $project['id'])), 'color' => $color, 'not_defined' => empty($project['start_date']) || empty($project['end_date']), - 'users' => $this->projectPermission->getProjectUsers($project['id']), + 'users' => $this->projectUserRole->getAllUsersGroupedByRole($project['id']), ); } diff --git a/app/Formatter/UserFilterAutoCompleteFormatter.php b/app/Formatter/UserFilterAutoCompleteFormatter.php new file mode 100644 index 00000000..b98e0d69 --- /dev/null +++ b/app/Formatter/UserFilterAutoCompleteFormatter.php @@ -0,0 +1,38 @@ +query->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name')->findAll(); + + foreach ($users as &$user) { + $user['value'] = $user['username'].' (#'.$user['id'].')'; + + if (empty($user['name'])) { + $user['label'] = $user['username']; + } else { + $user['label'] = $user['name'].' ('.$user['username'].')'; + } + } + + return $users; + } +} diff --git a/app/Group/DatabaseBackendGroupProvider.php b/app/Group/DatabaseBackendGroupProvider.php new file mode 100644 index 00000000..a53516a0 --- /dev/null +++ b/app/Group/DatabaseBackendGroupProvider.php @@ -0,0 +1,34 @@ +group->search($input); + + foreach ($groups as $group) { + $result[] = new DatabaseGroupProvider($group); + } + + return $result; + } +} diff --git a/app/Group/DatabaseGroupProvider.php b/app/Group/DatabaseGroupProvider.php new file mode 100644 index 00000000..e00f36ba --- /dev/null +++ b/app/Group/DatabaseGroupProvider.php @@ -0,0 +1,66 @@ +group = $group; + } + + /** + * Get internal id + * + * @access public + * @return integer + */ + public function getInternalId() + { + return $this->group['id']; + } + + /** + * Get external id + * + * @access public + * @return string + */ + public function getExternalId() + { + return ''; + } + + /** + * Get group name + * + * @access public + * @return string + */ + public function getName() + { + return $this->group['name']; + } +} diff --git a/app/Group/LdapBackendGroupProvider.php b/app/Group/LdapBackendGroupProvider.php new file mode 100644 index 00000000..40273466 --- /dev/null +++ b/app/Group/LdapBackendGroupProvider.php @@ -0,0 +1,54 @@ +getLdapGroupPattern($input)); + + } catch (LdapException $e) { + $this->logger->error($e->getMessage()); + return array(); + } + } + + /** + * Get LDAP group pattern + * + * @access public + * @param string $input + * @return string + */ + public function getLdapGroupPattern($input) + { + if (empty(LDAP_GROUP_FILTER)) { + throw new LogicException('LDAP group filter empty, check the parameter LDAP_GROUP_FILTER'); + } + + return sprintf(LDAP_GROUP_FILTER, $input); + } +} diff --git a/app/Group/LdapGroupProvider.php b/app/Group/LdapGroupProvider.php new file mode 100644 index 00000000..b497d485 --- /dev/null +++ b/app/Group/LdapGroupProvider.php @@ -0,0 +1,76 @@ +dn = $dn; + $this->name = $name; + } + + /** + * Get internal id + * + * @access public + * @return integer + */ + public function getInternalId() + { + return ''; + } + + /** + * Get external id + * + * @access public + * @return string + */ + public function getExternalId() + { + return $this->dn; + } + + /** + * Get group name + * + * @access public + * @return string + */ + public function getName() + { + return $this->name; + } +} diff --git a/app/Helper/Url.php b/app/Helper/Url.php index edb26841..3658ef5f 100644 --- a/app/Helper/Url.php +++ b/app/Helper/Url.php @@ -125,7 +125,7 @@ class Url extends Base return 'http://localhost/'; } - $url = Request::isHTTPS() ? 'https://' : 'http://'; + $url = $this->request->isHTTPS() ? 'https://' : 'http://'; $url .= $_SERVER['SERVER_NAME']; $url .= $_SERVER['SERVER_PORT'] == 80 || $_SERVER['SERVER_PORT'] == 443 ? '' : ':'.$_SERVER['SERVER_PORT']; $url .= $this->dir() ?: '/'; diff --git a/app/Helper/User.php b/app/Helper/User.php index 9ef20b38..b242dbb4 100644 --- a/app/Helper/User.php +++ b/app/Helper/User.php @@ -2,6 +2,8 @@ namespace Kanboard\Helper; +use Kanboard\Core\Security\Role; + /** * User helpers * @@ -65,6 +67,7 @@ class User extends \Kanboard\Core\Base array('user_id' => $this->userSession->getId()) ); } + /** * Check if the given user_id is the connected user * @@ -88,44 +91,77 @@ class User extends \Kanboard\Core\Base } /** - * Return if the logged user is project admin + * Get role name * * @access public - * @return boolean + * @param string $role + * @return string */ - public function isProjectAdmin() + public function getRoleName($role = '') { - return $this->userSession->isProjectAdmin(); + return $this->role->getRoleName($role ?: $this->userSession->getRole()); } /** - * Check for project administration actions access (Project Admin group) + * Check application access * - * @access public - * @return boolean + * @param string $controller + * @param string $action + * @return bool */ - public function isProjectAdministrationAllowed($project_id) + public function hasAccess($controller, $action) { - if ($this->userSession->isAdmin()) { - return true; + $key = 'app_access:'.$controller.$action; + $result = $this->memoryCache->get($key); + + if ($result === null) { + $result = $this->applicationAuthorization->isAllowed($controller, $action, $this->userSession->getRole()); + $this->memoryCache->set($key, $result); } - return $this->memoryCache->proxy($this->container['acl'], 'handleProjectAdminPermissions', $project_id); + return $result; } /** - * Check for project management actions access (Regular users who are Project Managers) + * Check project access * - * @access public - * @return boolean + * @param string $controller + * @param string $action + * @param integer $project_id + * @return bool */ - public function isProjectManagementAllowed($project_id) + public function hasProjectAccess($controller, $action, $project_id) { if ($this->userSession->isAdmin()) { return true; } - return $this->memoryCache->proxy($this->container['acl'], 'handleProjectManagerPermissions', $project_id); + if (! $this->hasAccess($controller, $action)) { + return false; + } + + $key = 'project_access:'.$controller.$action.$project_id; + $result = $this->memoryCache->get($key); + + if ($result === null) { + $role = $this->getProjectUserRole($project_id); + $result = $this->projectAuthorization->isAllowed($controller, $action, $role); + $this->memoryCache->set($key, $result); + } + + return $result; + } + + /** + * Get project role for the current user + * + * @access public + * @param integer $project_id + * @return string + */ + public function getProjectUserRole($project_id) + { + return $this->memoryCache->proxy($this->projectUserRole, 'getUserRole', $project_id, $this->userSession->getId()); } /** diff --git a/app/Model/Acl.php b/app/Model/Acl.php deleted file mode 100644 index 62f850cb..00000000 --- a/app/Model/Acl.php +++ /dev/null @@ -1,289 +0,0 @@ - array('login', 'check', 'captcha'), - 'task' => array('readonly'), - 'board' => array('readonly'), - 'webhook' => '*', - 'ical' => '*', - 'feed' => '*', - 'oauth' => array('google', 'github', 'gitlab'), - ); - - /** - * Controllers and actions for project members - * - * @access private - * @var array - */ - private $project_member_acl = array( - 'board' => '*', - 'comment' => '*', - 'file' => '*', - 'project' => array('show'), - 'listing' => '*', - 'activity' => '*', - 'subtask' => '*', - 'task' => '*', - 'taskduplication' => '*', - 'taskcreation' => '*', - 'taskmodification' => '*', - 'taskstatus' => '*', - 'tasklink' => '*', - 'timer' => '*', - 'customfilter' => '*', - 'calendar' => array('show', 'project'), - ); - - /** - * Controllers and actions for project managers - * - * @access private - * @var array - */ - private $project_manager_acl = array( - 'action' => '*', - 'analytic' => '*', - 'category' => '*', - 'column' => '*', - 'export' => '*', - 'taskimport' => '*', - 'project' => array('edit', 'update', 'share', 'integrations', 'notifications', 'users', 'alloweverybody', 'allow', 'setowner', 'revoke', 'duplicate', 'disable', 'enable'), - 'swimlane' => '*', - 'gantt' => array('project', 'savetaskdate', 'task', 'savetask'), - ); - - /** - * Controllers and actions for project admins - * - * @access private - * @var array - */ - private $project_admin_acl = array( - 'project' => array('remove'), - 'projectuser' => '*', - 'gantt' => array('projects', 'saveprojectdate'), - ); - - /** - * Controllers and actions for admins - * - * @access private - * @var array - */ - private $admin_acl = array( - 'user' => array('index', 'create', 'save', 'remove', 'authentication'), - 'userimport' => '*', - 'config' => '*', - 'link' => '*', - 'currency' => '*', - 'twofactor' => array('disable'), - ); - - /** - * Extend ACL rules - * - * @access public - * @param string $acl_name - * @param aray $rules - */ - public function extend($acl_name, array $rules) - { - $this->$acl_name = array_merge($this->$acl_name, $rules); - } - - /** - * Return true if the specified controller/action match the given acl - * - * @access public - * @param array $acl Acl list - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function matchAcl(array $acl, $controller, $action) - { - $controller = strtolower($controller); - $action = strtolower($action); - return isset($acl[$controller]) && $this->hasAction($action, $acl[$controller]); - } - - /** - * Return true if the specified action is inside the list of actions - * - * @access public - * @param string $action Action name - * @param mixed $action Actions list - * @return bool - */ - public function hasAction($action, $actions) - { - if (is_array($actions)) { - return in_array($action, $actions); - } - - return $actions === '*'; - } - - /** - * Return true if the given action is public - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function isPublicAction($controller, $action) - { - return $this->matchAcl($this->public_acl, $controller, $action); - } - - /** - * Return true if the given action is for admins - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function isAdminAction($controller, $action) - { - return $this->matchAcl($this->admin_acl, $controller, $action); - } - - /** - * Return true if the given action is for project managers - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function isProjectManagerAction($controller, $action) - { - return $this->matchAcl($this->project_manager_acl, $controller, $action); - } - - /** - * Return true if the given action is for application managers - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function isProjectAdminAction($controller, $action) - { - return $this->matchAcl($this->project_admin_acl, $controller, $action); - } - - /** - * Return true if the given action is for project members - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @return bool - */ - public function isProjectMemberAction($controller, $action) - { - return $this->matchAcl($this->project_member_acl, $controller, $action); - } - - /** - * Return true if the visitor is allowed to access to the given page - * We suppose the user already authenticated - * - * @access public - * @param string $controller Controller name - * @param string $action Action name - * @param integer $project_id Project id - * @return bool - */ - public function isAllowed($controller, $action, $project_id = 0) - { - // If you are admin you have access to everything - if ($this->userSession->isAdmin()) { - return true; - } - - // If you access to an admin action, your are not allowed - if ($this->isAdminAction($controller, $action)) { - return false; - } - - // Check project admin permissions - if ($this->isProjectAdminAction($controller, $action)) { - return $this->handleProjectAdminPermissions($project_id); - } - - // Check project manager permissions - if ($this->isProjectManagerAction($controller, $action)) { - return $this->handleProjectManagerPermissions($project_id); - } - - // Check project member permissions - if ($this->isProjectMemberAction($controller, $action)) { - return $project_id > 0 && $this->projectPermission->isMember($project_id, $this->userSession->getId()); - } - - // Other applications actions are allowed - return true; - } - - /** - * Handle permission for project manager - * - * @access public - * @param integer $project_id - * @return boolean - */ - public function handleProjectManagerPermissions($project_id) - { - if ($project_id > 0) { - if ($this->userSession->isProjectAdmin()) { - return $this->projectPermission->isMember($project_id, $this->userSession->getId()); - } - - return $this->projectPermission->isManager($project_id, $this->userSession->getId()); - } - - return false; - } - - /** - * Handle permission for project admins - * - * @access public - * @param integer $project_id - * @return boolean - */ - public function handleProjectAdminPermissions($project_id) - { - if (! $this->userSession->isProjectAdmin()) { - return false; - } - - if ($project_id > 0) { - return $this->projectPermission->isMember($project_id, $this->userSession->getId()); - } - - return true; - } -} diff --git a/app/Model/Authentication.php b/app/Model/Authentication.php index 83d85433..d10f2bf8 100644 --- a/app/Model/Authentication.php +++ b/app/Model/Authentication.php @@ -2,7 +2,6 @@ namespace Kanboard\Model; -use Kanboard\Core\Http\Request; use SimpleValidator\Validator; use SimpleValidator\Validators; use Gregwar\Captcha\CaptchaBuilder; @@ -15,113 +14,6 @@ use Gregwar\Captcha\CaptchaBuilder; */ class Authentication extends Base { - /** - * Load automatically an authentication backend - * - * @access public - * @param string $name Backend class name - * @return mixed - */ - public function backend($name) - { - if (! isset($this->container[$name])) { - $class = '\Kanboard\Auth\\'.ucfirst($name); - $this->container[$name] = new $class($this->container); - } - - return $this->container[$name]; - } - - /** - * Check if the current user is authenticated - * - * @access public - * @return bool - */ - public function isAuthenticated() - { - // If the user is already logged it's ok - if ($this->userSession->isLogged()) { - - // Check if the user session match an existing user - $userNotFound = ! $this->user->exists($this->userSession->getId()); - $reverseProxyWrongUser = REVERSE_PROXY_AUTH && $this->backend('reverseProxy')->getUsername() !== $this->userSession->getUsername(); - - if ($userNotFound || $reverseProxyWrongUser) { - $this->backend('rememberMe')->destroy($this->userSession->getId()); - $this->sessionManager->close(); - return false; - } - - return true; - } - - // We try first with the RememberMe cookie - if (REMEMBER_ME_AUTH && $this->backend('rememberMe')->authenticate()) { - return true; - } - - // Then with the ReverseProxy authentication - if (REVERSE_PROXY_AUTH && $this->backend('reverseProxy')->authenticate()) { - return true; - } - - return false; - } - - /** - * Authenticate a user by different methods - * - * @access public - * @param string $username Username - * @param string $password Password - * @return boolean - */ - public function authenticate($username, $password) - { - if ($this->user->isLocked($username)) { - $this->container['logger']->error('Account locked: '.$username); - return false; - } elseif ($this->backend('database')->authenticate($username, $password)) { - $this->user->resetFailedLogin($username); - return true; - } elseif (LDAP_AUTH && $this->backend('ldap')->authenticate($username, $password)) { - $this->user->resetFailedLogin($username); - return true; - } - - $this->handleFailedLogin($username); - return false; - } - - /** - * Return true if the captcha must be shown - * - * @access public - * @param string $username - * @return boolean - */ - public function hasCaptcha($username) - { - return $this->user->getFailedLogin($username) >= BRUTEFORCE_CAPTCHA; - } - - /** - * Handle failed login - * - * @access public - * @param string $username - */ - public function handleFailedLogin($username) - { - $this->user->incrementFailedLogin($username); - - if ($this->user->getFailedLogin($username) >= BRUTEFORCE_LOCKDOWN) { - $this->container['logger']->critical('Locking account: '.$username); - $this->user->lock($username, BRUTEFORCE_LOCKDOWN_DURATION); - } - } - /** * Validate user login form * @@ -131,14 +23,14 @@ class Authentication extends Base */ public function validateForm(array $values) { - list($result, $errors) = $this->validateFormCredentials($values); + $result = false; + $errors = array(); - if ($result) { - if ($this->validateFormCaptcha($values) && $this->authenticate($values['username'], $values['password'])) { - $this->createRememberMeSession($values); - } else { - $result = false; - $errors['login'] = t('Bad username or password'); + foreach (array('validateFields', 'validateLocking', 'validateCaptcha', 'validateCredentials') as $method) { + list($result, $errors) = $this->$method($values); + + if (! $result) { + break; } } @@ -148,11 +40,11 @@ class Authentication extends Base /** * Validate credentials syntax * - * @access public + * @access private * @param array $values Form values * @return array $valid, $errors [0] = Success or not, [1] = List of errors */ - public function validateFormCredentials(array $values) + private function validateFields(array $values) { $v = new Validator($values, array( new Validators\Required('username', t('The username is required')), @@ -167,40 +59,72 @@ class Authentication extends Base } /** - * Validate captcha + * Validate user locking * - * @access public + * @access private * @param array $values Form values - * @return boolean + * @return array $valid, $errors [0] = Success or not, [1] = List of errors */ - public function validateFormCaptcha(array $values) + private function validateLocking(array $values) { - if ($this->hasCaptcha($values['username'])) { - if (! isset($this->sessionStorage->captcha)) { - return false; - } + $result = true; + $errors = array(); - $builder = new CaptchaBuilder; - $builder->setPhrase($this->sessionStorage->captcha); - return $builder->testPhrase(isset($values['captcha']) ? $values['captcha'] : ''); + if ($this->userLocking->isLocked($values['username'])) { + $result = false; + $errors['login'] = t('Your account is locked for %d minutes', BRUTEFORCE_LOCKDOWN_DURATION); + $this->logger->error('Account locked: '.$values['username']); } - return true; + return array($result, $errors); } /** - * Create remember me session if necessary + * Validate password syntax * * @access private * @param array $values Form values + * @return array $valid, $errors [0] = Success or not, [1] = List of errors */ - private function createRememberMeSession(array $values) + private function validateCredentials(array $values) { - if (REMEMBER_ME_AUTH && ! empty($values['remember_me'])) { - $credentials = $this->backend('rememberMe') - ->create($this->userSession->getId(), Request::getIpAddress(), Request::getUserAgent()); + $result = true; + $errors = array(); - $this->backend('rememberMe')->writeCookie($credentials['token'], $credentials['sequence'], $credentials['expiration']); + if (! $this->authenticationManager->passwordAuthentication($values['username'], $values['password'])) { + $result = false; + $errors['login'] = t('Bad username or password'); } + + return array($result, $errors); + } + + /** + * Validate captcha + * + * @access private + * @param array $values Form values + * @return boolean + */ + private function validateCaptcha(array $values) + { + $result = true; + $errors = array(); + + if ($this->userLocking->hasCaptcha($values['username'])) { + if (! isset($this->sessionStorage->captcha)) { + $result = false; + } else { + $builder = new CaptchaBuilder; + $builder->setPhrase($this->sessionStorage->captcha); + $result = $builder->testPhrase(isset($values['captcha']) ? $values['captcha'] : ''); + + if (! $result) { + $errors['login'] = t('Invalid captcha'); + } + } + } + + return array($result, $errors);; } } diff --git a/app/Model/Group.php b/app/Model/Group.php index 82a8887b..36171ca4 100644 --- a/app/Model/Group.php +++ b/app/Model/Group.php @@ -43,6 +43,18 @@ class Group extends Base return $this->getQuery()->eq('id', $group_id)->findOne(); } + /** + * Get a specific group by external id + * + * @access public + * @param integer $external_id + * @return array + */ + public function getByExternalId($external_id) + { + return $this->getQuery()->eq('external_id', $external_id)->findOne(); + } + /** * Get all groups * @@ -54,6 +66,18 @@ class Group extends Base return $this->getQuery()->asc('name')->findAll(); } + /** + * Search groups by name + * + * @access public + * @param string $input + * @return array + */ + public function search($input) + { + return $this->db->table(self::TABLE)->ilike('name', '%'.$input.'%')->findAll(); + } + /** * Remove a group * diff --git a/app/Model/GroupMember.php b/app/Model/GroupMember.php index 04e9d495..7ed5f733 100644 --- a/app/Model/GroupMember.php +++ b/app/Model/GroupMember.php @@ -65,8 +65,8 @@ class GroupMember extends Base * Add user to a group * * @access public - * @param integer $group_id - * @param integer $user_id + * @param integer $group_id + * @param integer $user_id * @return boolean */ public function addUser($group_id, $user_id) @@ -81,8 +81,8 @@ class GroupMember extends Base * Remove user from a group * * @access public - * @param integer $group_id - * @param integer $user_id + * @param integer $group_id + * @param integer $user_id * @return boolean */ public function removeUser($group_id, $user_id) @@ -92,4 +92,20 @@ class GroupMember extends Base ->eq('user_id', $user_id) ->remove(); } + + /** + * Check if a user is member + * + * @access public + * @param integer $group_id + * @param integer $user_id + * @return boolean + */ + public function isMember($group_id, $user_id) + { + return $this->db->table(self::TABLE) + ->eq('group_id', $group_id) + ->eq('user_id', $user_id) + ->exists(); + } } diff --git a/app/Model/Project.php b/app/Model/Project.php index a7f93099..8a949ba6 100644 --- a/app/Model/Project.php +++ b/app/Model/Project.php @@ -5,6 +5,7 @@ namespace Kanboard\Model; use SimpleValidator\Validator; use SimpleValidator\Validators; use Kanboard\Core\Security\Token; +use Kanboard\Core\Security\Role; /** * Project model @@ -287,7 +288,7 @@ class Project extends Base { foreach ($projects as &$project) { $this->getColumnStats($project); - $project = array_merge($project, $this->projectPermission->getProjectUsers($project['id'])); + $project = array_merge($project, $this->projectUserRole->getAllUsersGroupedByRole($project['id'])); } return $projects; @@ -365,7 +366,7 @@ class Project extends Base } if ($add_user && $user_id) { - $this->projectPermission->addManager($project_id, $user_id); + $this->projectUserRole->addUser($project_id, $user_id, Role::PROJECT_MANAGER); } $this->category->createDefaultCategories($project_id); diff --git a/app/Model/ProjectAnalytic.php b/app/Model/ProjectAnalytic.php index 92364c0c..e77a0368 100644 --- a/app/Model/ProjectAnalytic.php +++ b/app/Model/ProjectAnalytic.php @@ -56,7 +56,7 @@ class ProjectAnalytic extends Base $metrics = array(); $total = 0; $tasks = $this->taskFinder->getAll($project_id); - $users = $this->projectPermission->getMemberList($project_id); + $users = $this->projectUserRole->getAssignableUsersList($project_id); foreach ($tasks as $task) { $user = isset($users[$task['owner_id']]) ? $users[$task['owner_id']] : $users[0]; diff --git a/app/Model/ProjectGroupRole.php b/app/Model/ProjectGroupRole.php new file mode 100644 index 00000000..87fdec10 --- /dev/null +++ b/app/Model/ProjectGroupRole.php @@ -0,0 +1,187 @@ +db + ->hashtable(Project::TABLE) + ->join(self::TABLE, 'project_id', 'id') + ->join(GroupMember::TABLE, 'group_id', 'group_id', self::TABLE) + ->eq(GroupMember::TABLE.'.user_id', $user_id) + ->in(Project::TABLE.'.is_active', $status) + ->getAll(Project::TABLE.'.id', Project::TABLE.'.name'); + } + + /** + * For a given project get the role of the specified user + * + * @access public + * @param integer $project_id + * @param integer $user_id + * @return string + */ + public function getUserRole($project_id, $user_id) + { + return $this->db->table(self::TABLE) + ->join(GroupMember::TABLE, 'group_id', 'group_id', self::TABLE) + ->eq(GroupMember::TABLE.'.user_id', $user_id) + ->eq(self::TABLE.'.project_id', $project_id) + ->findOneColumn('role'); + } + + /** + * Get all groups associated directly to the project + * + * @access public + * @param integer $project_id + * @return array + */ + public function getGroups($project_id) + { + return $this->db->table(self::TABLE) + ->columns(Group::TABLE.'.id', Group::TABLE.'.name', self::TABLE.'.role') + ->join(Group::TABLE, 'id', 'group_id') + ->eq('project_id', $project_id) + ->asc('name') + ->findAll(); + } + + /** + * From groups get all users associated to the project + * + * @access public + * @param integer $project_id + * @return array + */ + public function getUsers($project_id) + { + return $this->db->table(self::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', self::TABLE.'.role') + ->join(GroupMember::TABLE, 'group_id', 'group_id', self::TABLE) + ->join(User::TABLE, 'id', 'user_id', GroupMember::TABLE) + ->eq(self::TABLE.'.project_id', $project_id) + ->asc(User::TABLE.'.username') + ->findAll(); + } + + /** + * From groups get all users assignable to tasks + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAssignableUsers($project_id) + { + return $this->db->table(self::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name') + ->join(GroupMember::TABLE, 'group_id', 'group_id', self::TABLE) + ->join(User::TABLE, 'id', 'user_id', GroupMember::TABLE) + ->eq(self::TABLE.'.project_id', $project_id) + ->in(self::TABLE.'.role', array(Role::PROJECT_MANAGER, Role::PROJECT_MEMBER)) + ->asc(User::TABLE.'.username') + ->findAll(); + } + + /** + * Add a group to the project + * + * @access public + * @param integer $project_id + * @param integer $group_id + * @param string $role + * @return boolean + */ + public function addGroup($project_id, $group_id, $role) + { + return $this->db->table(self::TABLE)->insert(array( + 'group_id' => $group_id, + 'project_id' => $project_id, + 'role' => $role, + )); + } + + /** + * Remove a group from the project + * + * @access public + * @param integer $project_id + * @param integer $group_id + * @return boolean + */ + public function removeGroup($project_id, $group_id) + { + return $this->db->table(self::TABLE)->eq('group_id', $group_id)->eq('project_id', $project_id)->remove(); + } + + /** + * Change a group role for the project + * + * @access public + * @param integer $project_id + * @param integer $group_id + * @param string $role + * @return boolean + */ + public function changeGroupRole($project_id, $group_id, $role) + { + return $this->db->table(self::TABLE) + ->eq('group_id', $group_id) + ->eq('project_id', $project_id) + ->update(array( + 'role' => $role, + )); + } + + /** + * Copy group access from a project to another one + * + * @param integer $project_src_id Project Template + * @return integer $project_dst_id Project that receives the copy + * @return boolean + */ + public function duplicate($project_src_id, $project_dst_id) + { + $rows = $this->db->table(self::TABLE)->eq('project_id', $project_src_id)->findAll(); + + foreach ($rows as $row) { + $result = $this->db->table(self::TABLE)->save(array( + 'project_id' => $project_dst_id, + 'group_id' => $row['group_id'], + 'role' => $row['role'], + )); + + if (! $result) { + return false; + } + } + + return true; + } +} diff --git a/app/Model/ProjectPermission.php b/app/Model/ProjectPermission.php index d9eef4db..b311c10b 100644 --- a/app/Model/ProjectPermission.php +++ b/app/Model/ProjectPermission.php @@ -2,129 +2,25 @@ namespace Kanboard\Model; -use SimpleValidator\Validator; -use SimpleValidator\Validators; +use Kanboard\Core\Security\Role; /** - * Project permission model + * Project Permission * * @package model * @author Frederic Guillot */ class ProjectPermission extends Base { - /** - * SQL table name for permissions - * - * @var string - */ - const TABLE = 'project_has_users'; - - /** - * Get a list of people that can be assigned for tasks - * - * @access public - * @param integer $project_id Project id - * @param bool $prepend_unassigned Prepend the 'Unassigned' value - * @param bool $prepend_everybody Prepend the 'Everbody' value - * @param bool $allow_single_user If there is only one user return only this user - * @return array - */ - public function getMemberList($project_id, $prepend_unassigned = true, $prepend_everybody = false, $allow_single_user = false) - { - $allowed_users = $this->getMembers($project_id); - - if ($allow_single_user && count($allowed_users) === 1) { - return $allowed_users; - } - - if ($prepend_unassigned) { - $allowed_users = array(t('Unassigned')) + $allowed_users; - } - - if ($prepend_everybody) { - $allowed_users = array(User::EVERYBODY_ID => t('Everybody')) + $allowed_users; - } - - return $allowed_users; - } - - /** - * Get a list of members and managers with a single SQL query - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getProjectUsers($project_id) - { - $result = array( - 'managers' => array(), - 'members' => array(), - ); - - $users = $this->db - ->table(self::TABLE) - ->join(User::TABLE, 'id', 'user_id') - ->eq('project_id', $project_id) - ->asc('username') - ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', self::TABLE.'.is_owner') - ->findAll(); - - foreach ($users as $user) { - $key = $user['is_owner'] == 1 ? 'managers' : 'members'; - $result[$key][$user['id']] = $user['name'] ?: $user['username']; - } - - return $result; - } - - /** - * Get a list of allowed people for a project - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getMembers($project_id) - { - if ($this->isEverybodyAllowed($project_id)) { - return $this->user->getList(); - } - - return $this->getAssociatedUsers($project_id); - } - - /** - * Get a list of owners for a project - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getManagers($project_id) - { - $users = $this->db - ->table(self::TABLE) - ->join(User::TABLE, 'id', 'user_id') - ->eq('project_id', $project_id) - ->eq('is_owner', 1) - ->asc('username') - ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name') - ->findAll(); - - return $this->user->prepareList($users); - } - /** * Get query for project users overview * * @access public * @param array $project_ids - * @param integer $is_owner + * @param string $role * @return \PicoDb\Table */ - public function getQueryByRole(array $project_ids, $is_owner = 0) + public function getQueryByRole(array $project_ids, $role) { if (empty($project_ids)) { $project_ids = array(-1); @@ -135,7 +31,7 @@ class ProjectPermission extends Base ->table(self::TABLE) ->join(User::TABLE, 'id', 'user_id') ->join(Project::TABLE, 'id', 'project_id') - ->eq(self::TABLE.'.is_owner', $is_owner) + ->eq(self::TABLE.'.role', $role) ->eq(Project::TABLE.'.is_private', 0) ->in(Project::TABLE.'.id', $project_ids) ->columns( @@ -147,172 +43,6 @@ class ProjectPermission extends Base ); } - /** - * Get a list of people associated to the project - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getAssociatedUsers($project_id) - { - $users = $this->db - ->table(self::TABLE) - ->join(User::TABLE, 'id', 'user_id') - ->eq('project_id', $project_id) - ->asc('username') - ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name') - ->findAll(); - - return $this->user->prepareList($users); - } - - /** - * Get allowed and not allowed users for a project - * - * @access public - * @param integer $project_id Project id - * @return array - */ - public function getAllUsers($project_id) - { - $users = array( - 'allowed' => array(), - 'not_allowed' => array(), - 'managers' => array(), - ); - - $all_users = $this->user->getList(); - - $users['allowed'] = $this->getMembers($project_id); - $users['managers'] = $this->getManagers($project_id); - - foreach ($all_users as $user_id => $username) { - if (! isset($users['allowed'][$user_id])) { - $users['not_allowed'][$user_id] = $username; - } - } - - return $users; - } - - /** - * Add a new project member - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function addMember($project_id, $user_id) - { - return $this->db - ->table(self::TABLE) - ->save(array('project_id' => $project_id, 'user_id' => $user_id)); - } - - /** - * Remove a member - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function revokeMember($project_id, $user_id) - { - return $this->db - ->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('user_id', $user_id) - ->remove(); - } - - /** - * Add a project manager - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function addManager($project_id, $user_id) - { - return $this->db - ->table(self::TABLE) - ->save(array('project_id' => $project_id, 'user_id' => $user_id, 'is_owner' => 1)); - } - - /** - * Change the role of a member - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @param integer $is_owner Is user owner of the project - * @return bool - */ - public function changeRole($project_id, $user_id, $is_owner) - { - return $this->db - ->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('user_id', $user_id) - ->update(array('is_owner' => (int) $is_owner)); - } - - /** - * Check if a specific user is member of a project - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function isMember($project_id, $user_id) - { - if ($this->isEverybodyAllowed($project_id)) { - return true; - } - - return $this->db - ->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('user_id', $user_id) - ->exists(); - } - - /** - * Check if a specific user is manager of a given project - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function isManager($project_id, $user_id) - { - return $this->db - ->table(self::TABLE) - ->eq('project_id', $project_id) - ->eq('user_id', $user_id) - ->eq('is_owner', 1) - ->exists(); - } - - /** - * Check if a specific user is allowed to access to a given project - * - * @access public - * @param integer $project_id Project id - * @param integer $user_id User id - * @return bool - */ - public function isUserAllowed($project_id, $user_id) - { - return $project_id === 0 || $this->user->isAdmin($user_id) || $this->isMember($project_id, $user_id); - } - /** * Return true if everybody is allowed for the project * @@ -330,172 +60,59 @@ class ProjectPermission extends Base } /** - * Return a list of allowed active projects for a given user + * Return true if the user is allowed to access a project * - * @access public - * @param integer $user_id User id - * @return array + * @param integer $project_id + * @param integer $user_id + * @return boolean */ - public function getAllowedProjects($user_id) + public function isUserAllowed($project_id, $user_id) { - if ($this->user->isAdmin($user_id)) { - return $this->project->getListByStatus(Project::ACTIVE); + if ($this->userSession->isAdmin()) { + return true; } - return $this->getActiveMemberProjects($user_id); - } - - /** - * Return a list of projects where the user is member - * - * @access public - * @param integer $user_id User id - * @return array - */ - public function getMemberProjects($user_id) - { - return $this->db - ->hashtable(Project::TABLE) - ->beginOr() - ->eq(self::TABLE.'.user_id', $user_id) - ->eq(Project::TABLE.'.is_everybody_allowed', 1) - ->closeOr() - ->join(self::TABLE, 'project_id', 'id') - ->getAll('projects.id', 'name'); - } - - /** - * Return a list of project ids where the user is member - * - * @access public - * @param integer $user_id User id - * @return array - */ - public function getMemberProjectIds($user_id) - { - return $this->db - ->table(Project::TABLE) - ->beginOr() - ->eq(self::TABLE.'.user_id', $user_id) - ->eq(Project::TABLE.'.is_everybody_allowed', 1) - ->closeOr() - ->join(self::TABLE, 'project_id', 'id') - ->findAllByColumn('projects.id'); + return in_array( + $this->projectUserRole->getUserRole($project_id, $user_id), + array(Role::PROJECT_MANAGER, Role::PROJECT_MEMBER, Role::PROJECT_VIEWER) + ); } /** - * Return a list of active project ids where the user is member + * Return true if the user is assignable * * @access public - * @param integer $user_id User id - * @return array + * @param integer $project_id + * @param integer $user_id + * @return boolean */ - public function getActiveMemberProjectIds($user_id) + public function isMember($project_id, $user_id) { - return $this->db - ->table(Project::TABLE) - ->beginOr() - ->eq(self::TABLE.'.user_id', $user_id) - ->eq(Project::TABLE.'.is_everybody_allowed', 1) - ->closeOr() - ->eq(Project::TABLE.'.is_active', Project::ACTIVE) - ->join(self::TABLE, 'project_id', 'id') - ->findAllByColumn('projects.id'); + return in_array($this->projectUserRole->getUSerRole($project_id, $user_id), array(Role::PROJECT_MEMBER, Role::PROJECT_MANAGER)); } /** - * Return a list of active projects where the user is member + * Get active project ids by user * * @access public - * @param integer $user_id User id + * @param integer $user_id * @return array */ - public function getActiveMemberProjects($user_id) + public function getActiveProjectIds($user_id) { - return $this->db - ->hashtable(Project::TABLE) - ->beginOr() - ->eq(self::TABLE.'.user_id', $user_id) - ->eq(Project::TABLE.'.is_everybody_allowed', 1) - ->closeOr() - ->eq(Project::TABLE.'.is_active', Project::ACTIVE) - ->join(self::TABLE, 'project_id', 'id') - ->getAll('projects.id', 'name'); + return array_keys($this->projectUserRole->getProjectsByUser($user_id, array(Project::ACTIVE))); } /** - * Copy user access from a project to another one + * Copy permissions to another project * - * @param integer $project_src Project Template - * @return integer $project_dst Project that receives the copy + * @param integer $project_src_id Project Template + * @param integer $project_dst_id Project that receives the copy * @return boolean */ - public function duplicate($project_src, $project_dst) - { - $rows = $this->db - ->table(self::TABLE) - ->columns('project_id', 'user_id', 'is_owner') - ->eq('project_id', $project_src) - ->findAll(); - - foreach ($rows as $row) { - $result = $this->db - ->table(self::TABLE) - ->save(array( - 'project_id' => $project_dst, - 'user_id' => $row['user_id'], - 'is_owner' => (int) $row['is_owner'], // (int) for postgres - )); - - if (! $result) { - return false; - } - } - - return true; - } - - /** - * Validate allow user - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateUserModification(array $values) + public function duplicate($project_src_id, $project_dst_id) { - $v = new Validator($values, array( - new Validators\Required('project_id', t('The project id is required')), - new Validators\Integer('project_id', t('This value must be an integer')), - new Validators\Required('user_id', t('The user id is required')), - new Validators\Integer('user_id', t('This value must be an integer')), - new Validators\Integer('is_owner', t('This value must be an integer')), - )); - - return array( - $v->execute(), - $v->getErrors() - ); - } - - /** - * Validate allow everybody - * - * @access public - * @param array $values Form values - * @return array $valid, $errors [0] = Success or not, [1] = List of errors - */ - public function validateProjectModification(array $values) - { - $v = new Validator($values, array( - new Validators\Required('id', t('The project id is required')), - new Validators\Integer('id', t('This value must be an integer')), - new Validators\Integer('is_everybody_allowed', t('This value must be an integer')), - )); - - return array( - $v->execute(), - $v->getErrors() - ); + return $this->projectUserRole->duplicate($project_src_id, $project_dst_id) && + $this->projectGroupRole->duplicate($project_src_id, $project_dst_id); } } diff --git a/app/Model/ProjectUserRole.php b/app/Model/ProjectUserRole.php new file mode 100644 index 00000000..28e6c8c6 --- /dev/null +++ b/app/Model/ProjectUserRole.php @@ -0,0 +1,263 @@ +db + ->hashtable(Project::TABLE) + ->beginOr() + ->eq(self::TABLE.'.user_id', $user_id) + ->eq(Project::TABLE.'.is_everybody_allowed', 1) + ->closeOr() + ->in(Project::TABLE.'.is_active', $status) + ->join(self::TABLE, 'project_id', 'id') + ->getAll(Project::TABLE.'.id', Project::TABLE.'.name'); + + $groupProjects = $this->projectGroupRole->getProjectsByUser($user_id, $status); + $groups = $userProjects + $groupProjects; + + asort($groups); + + return $groups; + } + + /** + * For a given project get the role of the specified user + * + * @access public + * @param integer $project_id + * @param integer $user_id + * @return string + */ + public function getUserRole($project_id, $user_id) + { + if ($this->projectPermission->isEverybodyAllowed($project_id)) { + return Role::PROJECT_MEMBER; + } + + $role = $this->db->table(self::TABLE)->eq('user_id', $user_id)->eq('project_id', $project_id)->findOneColumn('role'); + + if (empty($role)) { + $role = $this->projectGroupRole->getUserRole($project_id, $user_id); + } + + return $role; + } + + /** + * Get all users associated directly to the project + * + * @access public + * @param integer $project_id + * @return array + */ + public function getUsers($project_id) + { + return $this->db->table(self::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', self::TABLE.'.role') + ->join(User::TABLE, 'id', 'user_id') + ->eq('project_id', $project_id) + ->asc(User::TABLE.'.username') + ->asc(User::TABLE.'.name') + ->findAll(); + } + + /** + * Get all users (fetch users from groups) + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAllUsers($project_id) + { + $userMembers = $this->getUsers($project_id); + $groupMembers = $this->projectGroupRole->getUsers($project_id); + $members = array_merge($userMembers, $groupMembers); + + return $this->user->prepareList($members); + } + + /** + * Get users grouped by role + * + * @access public + * @param integer $project_id Project id + * @return array + */ + public function getAllUsersGroupedByRole($project_id) + { + $users = array(); + + $userMembers = $this->getUsers($project_id); + $groupMembers = $this->projectGroupRole->getUsers($project_id); + $members = array_merge($userMembers, $groupMembers); + + foreach ($members as $user) { + if (! isset($users[$user['role']])) { + $users[$user['role']] = array(); + } + + $users[$user['role']][$user['id']] = $user['name'] ?: $user['username']; + } + + return $users; + } + + /** + * Get list of users that can be assigned to a task (only Manager and Member) + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAssignableUsers($project_id) + { + if ($this->projectPermission->isEverybodyAllowed($project_id)) { + return $this->user->getList(); + } + + $userMembers = $this->db->table(self::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name') + ->join(User::TABLE, 'id', 'user_id') + ->eq('project_id', $project_id) + ->in(self::TABLE.'.role', array(Role::PROJECT_MANAGER, Role::PROJECT_MEMBER)) + ->findAll(); + + $groupMembers = $this->projectGroupRole->getAssignableUsers($project_id); + $members = array_merge($userMembers, $groupMembers); + + return $this->user->prepareList($members); + } + + /** + * Get list of users that can be assigned to a task (only Manager and Member) + * + * @access public + * @param integer $project_id Project id + * @param bool $unassigned Prepend the 'Unassigned' value + * @param bool $everybody Prepend the 'Everbody' value + * @param bool $singleUser If there is only one user return only this user + * @return array + */ + public function getAssignableUsersList($project_id, $unassigned = true, $everybody = false, $singleUser = false) + { + $users = $this->getAssignableUsers($project_id); + + if ($singleUser && count($users) === 1) { + return $users; + } + + if ($unassigned) { + $users = array(t('Unassigned')) + $users; + } + + if ($everybody) { + $users = array(User::EVERYBODY_ID => t('Everybody')) + $users; + } + + return $users; + } + + /** + * Add a user to the project + * + * @access public + * @param integer $project_id + * @param integer $user_id + * @param string $role + * @return boolean + */ + public function addUser($project_id, $user_id, $role) + { + return $this->db->table(self::TABLE)->insert(array( + 'user_id' => $user_id, + 'project_id' => $project_id, + 'role' => $role, + )); + } + + /** + * Remove a user from the project + * + * @access public + * @param integer $project_id + * @param integer $user_id + * @return boolean + */ + public function removeUser($project_id, $user_id) + { + return $this->db->table(self::TABLE)->eq('user_id', $user_id)->eq('project_id', $project_id)->remove(); + } + + /** + * Change a user role for the project + * + * @access public + * @param integer $project_id + * @param integer $user_id + * @param string $role + * @return boolean + */ + public function changeUserRole($project_id, $user_id, $role) + { + return $this->db->table(self::TABLE) + ->eq('user_id', $user_id) + ->eq('project_id', $project_id) + ->update(array( + 'role' => $role, + )); + } + + /** + * Copy user access from a project to another one + * + * @param integer $project_src_id Project Template + * @return integer $project_dst_id Project that receives the copy + * @return boolean + */ + public function duplicate($project_src_id, $project_dst_id) + { + $rows = $this->db->table(self::TABLE)->eq('project_id', $project_src_id)->findAll(); + + foreach ($rows as $row) { + $result = $this->db->table(self::TABLE)->save(array( + 'project_id' => $project_dst_id, + 'user_id' => $row['user_id'], + 'role' => $row['role'], + )); + + if (! $result) { + return false; + } + } + + return true; + } +} diff --git a/app/Model/RememberMeSession.php b/app/Model/RememberMeSession.php new file mode 100644 index 00000000..8989a6d7 --- /dev/null +++ b/app/Model/RememberMeSession.php @@ -0,0 +1,151 @@ +db + ->table(self::TABLE) + ->eq('token', $token) + ->eq('sequence', $sequence) + ->gt('expiration', time()) + ->findOne(); + } + + /** + * Get all sessions for a given user + * + * @access public + * @param integer $user_id User id + * @return array + */ + public function getAll($user_id) + { + return $this->db + ->table(self::TABLE) + ->eq('user_id', $user_id) + ->desc('date_creation') + ->columns('id', 'ip', 'user_agent', 'date_creation', 'expiration') + ->findAll(); + } + + /** + * Create a new RememberMe session + * + * @access public + * @param integer $user_id User id + * @param string $ip IP Address + * @param string $user_agent User Agent + * @return array + */ + public function create($user_id, $ip, $user_agent) + { + $token = hash('sha256', $user_id.$user_agent.$ip.Token::getToken()); + $sequence = Token::getToken(); + $expiration = time() + self::EXPIRATION; + + $this->cleanup($user_id); + + $this + ->db + ->table(self::TABLE) + ->insert(array( + 'user_id' => $user_id, + 'ip' => $ip, + 'user_agent' => $user_agent, + 'token' => $token, + 'sequence' => $sequence, + 'expiration' => $expiration, + 'date_creation' => time(), + )); + + return array( + 'token' => $token, + 'sequence' => $sequence, + 'expiration' => $expiration, + ); + } + + /** + * Remove a session record + * + * @access public + * @param integer $session_id Session id + * @return mixed + */ + public function remove($session_id) + { + return $this->db + ->table(self::TABLE) + ->eq('id', $session_id) + ->remove(); + } + + /** + * Remove old sessions for a given user + * + * @access public + * @param integer $user_id User id + * @return bool + */ + public function cleanup($user_id) + { + return $this->db + ->table(self::TABLE) + ->eq('user_id', $user_id) + ->lt('expiration', time()) + ->remove(); + } + + /** + * Return a new sequence token and update the database + * + * @access public + * @param string $token Session token + * @return string + */ + public function updateSequence($token) + { + $sequence = Token::getToken(); + + $this + ->db + ->table(self::TABLE) + ->eq('token', $token) + ->update(array('sequence' => $sequence)); + + return $sequence; + } +} diff --git a/app/Model/TaskPermission.php b/app/Model/TaskPermission.php index 4bbe6d1d..fac2153e 100644 --- a/app/Model/TaskPermission.php +++ b/app/Model/TaskPermission.php @@ -2,6 +2,8 @@ namespace Kanboard\Model; +use Kanboard\Core\Security\Role; + /** * Task permission model * @@ -20,7 +22,7 @@ class TaskPermission extends Base */ public function canRemoveTask(array $task) { - if ($this->userSession->isAdmin() || $this->projectPermission->isManager($task['project_id'], $this->userSession->getId())) { + if ($this->userSession->isAdmin() || $this->projectUserRole->getUserRole($task['project_id'], $this->userSession->getId()) === Role::PROJECT_MANAGER) { return true; } elseif (isset($task['creator_id']) && $task['creator_id'] == $this->userSession->getId()) { return true; diff --git a/app/Model/User.php b/app/Model/User.php index 88361ce8..7142c258 100644 --- a/app/Model/User.php +++ b/app/Model/User.php @@ -7,6 +7,7 @@ use SimpleValidator\Validator; use SimpleValidator\Validators; use Kanboard\Core\Session\SessionManager; use Kanboard\Core\Security\Token; +use Kanboard\Core\Security\Role; /** * User model @@ -57,8 +58,7 @@ class User extends Base 'username', 'name', 'email', - 'is_admin', - 'is_project_admin', + 'role', 'is_ldap_user', 'notifications_enabled', 'google_id', @@ -91,7 +91,7 @@ class User extends Base $this->db ->table(User::TABLE) ->eq('id', $user_id) - ->eq('is_admin', 1) + ->eq('role', Role::APP_ADMIN) ->exists(); } @@ -111,48 +111,17 @@ class User extends Base * Get a specific user by the Google id * * @access public - * @param string $google_id Google unique id + * @param string $column + * @param string $id * @return array|boolean */ - public function getByGoogleId($google_id) + public function getByExternalId($column, $id) { - if (empty($google_id)) { + if (empty($id)) { return false; } - return $this->db->table(self::TABLE)->eq('google_id', $google_id)->findOne(); - } - - /** - * Get a specific user by the Github id - * - * @access public - * @param string $github_id Github user id - * @return array|boolean - */ - public function getByGithubId($github_id) - { - if (empty($github_id)) { - return false; - } - - return $this->db->table(self::TABLE)->eq('github_id', $github_id)->findOne(); - } - - /** - * Get a specific user by the Gitlab id - * - * @access public - * @param string $gitlab_id Gitlab user id - * @return array|boolean - */ - public function getByGitlabId($gitlab_id) - { - if (empty($gitlab_id)) { - return false; - } - - return $this->db->table(self::TABLE)->eq('gitlab_id', $gitlab_id)->findOne(); + return $this->db->table(self::TABLE)->eq($column, $id)->findOne(); } /** @@ -289,7 +258,7 @@ class User extends Base } $this->removeFields($values, array('confirmation', 'current_password')); - $this->resetFields($values, array('is_admin', 'is_ldap_user', 'is_project_admin', 'disable_login_form')); + $this->resetFields($values, array('is_ldap_user', 'disable_login_form')); $this->convertNullFields($values, array('gitlab_id')); $this->convertIntegerFields($values, array('gitlab_id')); } @@ -355,10 +324,10 @@ class User extends Base // All private projects are removed $project_ids = $db->table(Project::TABLE) - ->eq('is_private', 1) - ->eq(ProjectPermission::TABLE.'.user_id', $user_id) - ->join(ProjectPermission::TABLE, 'project_id', 'id') - ->findAllByColumn(Project::TABLE.'.id'); + ->eq('is_private', 1) + ->eq(ProjectUserRole::TABLE.'.user_id', $user_id) + ->join(ProjectUserRole::TABLE, 'project_id', 'id') + ->findAllByColumn(Project::TABLE.'.id'); if (! empty($project_ids)) { $db->table(Project::TABLE)->in('id', $project_ids)->remove(); @@ -401,71 +370,6 @@ class User extends Base ->save(array('token' => '')); } - /** - * Get the number of failed login for the user - * - * @access public - * @param string $username - * @return integer - */ - public function getFailedLogin($username) - { - return (int) $this->db->table(self::TABLE)->eq('username', $username)->findOneColumn('nb_failed_login'); - } - - /** - * Reset to 0 the counter of failed login - * - * @access public - * @param string $username - * @return boolean - */ - public function resetFailedLogin($username) - { - return $this->db->table(self::TABLE)->eq('username', $username)->update(array('nb_failed_login' => 0, 'lock_expiration_date' => 0)); - } - - /** - * Increment failed login counter - * - * @access public - * @param string $username - * @return boolean - */ - public function incrementFailedLogin($username) - { - return $this->db->execute('UPDATE '.self::TABLE.' SET nb_failed_login=nb_failed_login+1 WHERE username=?', array($username)) !== false; - } - - /** - * Check if the account is locked - * - * @access public - * @param string $username - * @return boolean - */ - public function isLocked($username) - { - return $this->db->table(self::TABLE) - ->eq('username', $username) - ->neq('lock_expiration_date', 0) - ->gte('lock_expiration_date', time()) - ->exists(); - } - - /** - * Lock the account for the specified duration - * - * @access public - * @param string $username Username - * @param integer $duration Duration in minutes - * @return boolean - */ - public function lock($username, $duration = 15) - { - return $this->db->table(self::TABLE)->eq('username', $username)->update(array('lock_expiration_date' => time() + $duration * 60)); - } - /** * Common validation rules * @@ -475,11 +379,10 @@ class User extends Base private function commonValidationRules() { return array( + new Validators\MaxLength('role', t('The maximum length is %d characters', 25), 25), new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50), new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), self::TABLE, 'id'), new Validators\Email('email', t('Email address invalid')), - new Validators\Integer('is_admin', t('This value must be an integer')), - new Validators\Integer('is_project_admin', t('This value must be an integer')), new Validators\Integer('is_ldap_user', t('This value must be an integer')), ); } @@ -585,9 +488,7 @@ class User extends Base $v = new Validator($values, array_merge($rules, $this->commonPasswordValidationRules())); if ($v->execute()) { - - // Check password - if ($this->authentication->authenticate($this->userSession->getUsername(), $values['current_password'])) { + if ($this->authenticationManager->passwordAuthentication($this->userSession->getUsername(), $values['current_password'], false)) { return array(true, array()); } else { return array(false, array('current_password' => array(t('Wrong password')))); diff --git a/app/Model/UserFilter.php b/app/Model/UserFilter.php new file mode 100644 index 00000000..ff546e96 --- /dev/null +++ b/app/Model/UserFilter.php @@ -0,0 +1,80 @@ +query = $this->db->table(User::TABLE); + $this->input = $input; + return $this; + } + + /** + * Filter users by name or username + * + * @access public + * @return UserFilter + */ + public function filterByUsernameOrByName() + { + $this->query->beginOr() + ->ilike('username', '%'.$this->input.'%') + ->ilike('name', '%'.$this->input.'%') + ->closeOr(); + + return $this; + } + + /** + * Get all results of the filter + * + * @access public + * @return array + */ + public function findAll() + { + return $this->query->findAll(); + } + + /** + * Get the PicoDb query + * + * @access public + * @return \PicoDb\Table + */ + public function getQuery() + { + return $this->query; + } +} diff --git a/app/Model/UserImport.php b/app/Model/UserImport.php index 3c9e7a57..0ec4e802 100644 --- a/app/Model/UserImport.php +++ b/app/Model/UserImport.php @@ -4,6 +4,7 @@ namespace Kanboard\Model; use SimpleValidator\Validator; use SimpleValidator\Validators; +use Kanboard\Core\Security\Role; use Kanboard\Core\Csv; /** @@ -36,7 +37,7 @@ class UserImport extends Base 'email' => 'Email', 'name' => 'Full Name', 'is_admin' => 'Administrator', - 'is_project_admin' => 'Project Administrator', + 'is_manager' => 'Manager', 'is_ldap_user' => 'Remote User', ); } @@ -75,10 +76,21 @@ class UserImport extends Base { $row['username'] = strtolower($row['username']); - foreach (array('is_admin', 'is_project_admin', 'is_ldap_user') as $field) { + foreach (array('is_admin', 'is_manager', 'is_ldap_user') as $field) { $row[$field] = Csv::getBooleanValue($row[$field]); } + if ($row['is_admin'] == 1) { + $row['role'] = Role::APP_ADMIN; + } elseif ($row['is_manager'] == 1) { + $row['role'] = Role::APP_MANAGER; + } else { + $row['role'] = Role::APP_USER; + } + + unset($row['is_admin']); + unset($row['is_manager']); + $this->removeEmptyFields($row, array('password', 'email', 'name')); return $row; @@ -98,8 +110,6 @@ class UserImport extends Base new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), User::TABLE, 'id'), new Validators\MinLength('password', t('The minimum length is %d characters', 6), 6), new Validators\Email('email', t('Email address invalid')), - new Validators\Integer('is_admin', t('This value must be an integer')), - new Validators\Integer('is_project_admin', t('This value must be an integer')), new Validators\Integer('is_ldap_user', t('This value must be an integer')), )); diff --git a/app/Model/UserLocking.php b/app/Model/UserLocking.php new file mode 100644 index 00000000..67e4c244 --- /dev/null +++ b/app/Model/UserLocking.php @@ -0,0 +1,103 @@ +db->table(User::TABLE) + ->eq('username', $username) + ->findOneColumn('nb_failed_login'); + } + + /** + * Reset to 0 the counter of failed login + * + * @access public + * @param string $username + * @return boolean + */ + public function resetFailedLogin($username) + { + return $this->db->table(User::TABLE) + ->eq('username', $username) + ->update(array( + 'nb_failed_login' => 0, + 'lock_expiration_date' => 0, + )); + } + + /** + * Increment failed login counter + * + * @access public + * @param string $username + * @return boolean + */ + public function incrementFailedLogin($username) + { + return $this->db->table(User::TABLE) + ->eq('username', $username) + ->increment('nb_failed_login', 1); + } + + /** + * Check if the account is locked + * + * @access public + * @param string $username + * @return boolean + */ + public function isLocked($username) + { + return $this->db->table(User::TABLE) + ->eq('username', $username) + ->neq('lock_expiration_date', 0) + ->gte('lock_expiration_date', time()) + ->exists(); + } + + /** + * Lock the account for the specified duration + * + * @access public + * @param string $username Username + * @param integer $duration Duration in minutes + * @return boolean + */ + public function lock($username, $duration = 15) + { + return $this->db->table(User::TABLE) + ->eq('username', $username) + ->update(array( + 'lock_expiration_date' => time() + $duration * 60 + )); + } + + /** + * Return true if the captcha must be shown + * + * @access public + * @param string $username + * @param integer $tries + * @return boolean + */ + public function hasCaptcha($username, $tries = BRUTEFORCE_CAPTCHA) + { + return $this->getFailedLogin($username) >= $tries; + } +} diff --git a/app/Model/UserNotification.php b/app/Model/UserNotification.php index 3d98ebe9..e00f23c5 100644 --- a/app/Model/UserNotification.php +++ b/app/Model/UserNotification.php @@ -155,7 +155,7 @@ class UserNotification extends Base private function getProjectMembersWithNotificationEnabled($project_id, $exclude_user_id) { return $this->db - ->table(ProjectPermission::TABLE) + ->table(ProjectUserRole::TABLE) ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', User::TABLE.'.email', User::TABLE.'.language', User::TABLE.'.notifications_filter') ->join(User::TABLE, 'id', 'user_id') ->eq('project_id', $project_id) diff --git a/app/Model/UserSession.php b/app/Model/UserSession.php deleted file mode 100644 index a687952b..00000000 --- a/app/Model/UserSession.php +++ /dev/null @@ -1,195 +0,0 @@ -sessionStorage->user = $user; - $this->sessionStorage->postAuth = array('validated' => false); - } - - /** - * Return true if the user has validated the 2FA key - * - * @access public - * @return bool - */ - public function check2FA() - { - return isset($this->sessionStorage->postAuth['validated']) && $this->sessionStorage->postAuth['validated'] === true; - } - - /** - * Return true if the user has 2FA enabled - * - * @access public - * @return bool - */ - public function has2FA() - { - return isset($this->sessionStorage->user['twofactor_activated']) && $this->sessionStorage->user['twofactor_activated'] === true; - } - - /** - * Disable 2FA for the current session - * - * @access public - */ - public function disable2FA() - { - $this->sessionStorage->user['twofactor_activated'] = false; - } - - /** - * Return true if the logged user is admin - * - * @access public - * @return bool - */ - public function isAdmin() - { - return isset($this->sessionStorage->user['is_admin']) && $this->sessionStorage->user['is_admin'] === true; - } - - /** - * Return true if the logged user is project admin - * - * @access public - * @return bool - */ - public function isProjectAdmin() - { - return isset($this->sessionStorage->user['is_project_admin']) && $this->sessionStorage->user['is_project_admin'] === true; - } - - /** - * Get the connected user id - * - * @access public - * @return integer - */ - public function getId() - { - return isset($this->sessionStorage->user['id']) ? (int) $this->sessionStorage->user['id'] : 0; - } - - /** - * Get username - * - * @access public - * @return integer - */ - public function getUsername() - { - return isset($this->sessionStorage->user['username']) ? $this->sessionStorage->user['username'] : ''; - } - - /** - * Check is the user is connected - * - * @access public - * @return bool - */ - public function isLogged() - { - return isset($this->sessionStorage->user) && ! empty($this->sessionStorage->user); - } - - /** - * Get project filters from the session - * - * @access public - * @param integer $project_id - * @return string - */ - public function getFilters($project_id) - { - return ! empty($this->sessionStorage->filters[$project_id]) ? $this->sessionStorage->filters[$project_id] : 'status:open'; - } - - /** - * Save project filters in the session - * - * @access public - * @param integer $project_id - * @param string $filters - */ - public function setFilters($project_id, $filters) - { - $this->sessionStorage->filters[$project_id] = $filters; - } - - /** - * Is board collapsed or expanded - * - * @access public - * @param integer $project_id - * @return boolean - */ - public function isBoardCollapsed($project_id) - { - return ! empty($this->sessionStorage->boardCollapsed[$project_id]) ? $this->sessionStorage->boardCollapsed[$project_id] : false; - } - - /** - * Set board display mode - * - * @access public - * @param integer $project_id - * @param boolean $is_collapsed - */ - public function setBoardDisplayMode($project_id, $is_collapsed) - { - $this->sessionStorage->boardCollapsed[$project_id] = $is_collapsed; - } - - /** - * Set comments sorting - * - * @access public - * @param string $order - */ - public function setCommentSorting($order) - { - $this->sessionStorage->commentSorting = $order; - } - - /** - * Get comments sorting direction - * - * @access public - * @return string - */ - public function getCommentSorting() - { - return empty($this->sessionStorage->commentSorting) ? 'ASC' : $this->sessionStorage->commentSorting; - } -} diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php index 5a451c77..ac97e224 100644 --- a/app/Schema/Mysql.php +++ b/app/Schema/Mysql.php @@ -4,8 +4,67 @@ namespace Schema; use PDO; use Kanboard\Core\Security\Token; +use Kanboard\Core\Security\Role; -const VERSION = 95; +const VERSION = 97; + +function version_97(PDO $pdo) +{ + $pdo->exec("ALTER TABLE `users` ADD COLUMN `role` VARCHAR(25) NOT NULL DEFAULT '".Role::APP_USER."'"); + + $rq = $pdo->prepare('SELECT * FROM `users`'); + $rq->execute(); + $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array(); + + $rq = $pdo->prepare('UPDATE `users` SET `role`=? WHERE `id`=?'); + + foreach ($rows as $row) { + $role = Role::APP_USER; + + if ($row['is_admin'] == 1) { + $role = Role::APP_ADMIN; + } else if ($row['is_project_admin']) { + $role = Role::APP_MANAGER; + } + + $rq->execute(array($role, $row['id'])); + } + + $pdo->exec('ALTER TABLE `users` DROP COLUMN `is_admin`'); + $pdo->exec('ALTER TABLE `users` DROP COLUMN `is_project_admin`'); +} + +function version_96(PDO $pdo) +{ + $pdo->exec(" + CREATE TABLE project_has_groups ( + `group_id` INT NOT NULL, + `project_id` INT NOT NULL, + `role` VARCHAR(25) NOT NULL, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE(group_id, project_id) + ) ENGINE=InnoDB CHARSET=utf8 + "); + + $pdo->exec("ALTER TABLE `project_has_users` ADD COLUMN `role` VARCHAR(25) NOT NULL DEFAULT '".Role::PROJECT_VIEWER."'"); + + $rq = $pdo->prepare('SELECT * FROM project_has_users'); + $rq->execute(); + $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array(); + + $rq = $pdo->prepare('UPDATE `project_has_users` SET `role`=? WHERE `id`=?'); + + foreach ($rows as $row) { + $rq->execute(array( + $row['is_owner'] == 1 ? Role::PROJECT_MANAGER : Role::PROJECT_MEMBER, + $row['id'], + )); + } + + $pdo->exec('ALTER TABLE `project_has_users` DROP COLUMN `is_owner`'); + $pdo->exec('ALTER TABLE `project_has_users` DROP COLUMN `id`'); +} function version_95(PDO $pdo) { diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php index a3887cfb..66d9acc1 100644 --- a/app/Schema/Postgres.php +++ b/app/Schema/Postgres.php @@ -4,8 +4,67 @@ namespace Schema; use PDO; use Kanboard\Core\Security\Token; +use Kanboard\Core\Security\Role; -const VERSION = 75; +const VERSION = 77; + +function version_77(PDO $pdo) +{ + $pdo->exec('ALTER TABLE "users" ADD COLUMN "role" VARCHAR(25) NOT NULL DEFAULT \''.Role::APP_USER.'\''); + + $rq = $pdo->prepare('SELECT * FROM "users"'); + $rq->execute(); + $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array(); + + $rq = $pdo->prepare('UPDATE "users" SET "role"=? WHERE "id"=?'); + + foreach ($rows as $row) { + $role = Role::APP_USER; + + if ($row['is_admin'] == 1) { + $role = Role::APP_ADMIN; + } else if ($row['is_project_admin']) { + $role = Role::APP_MANAGER; + } + + $rq->execute(array($role, $row['id'])); + } + + $pdo->exec('ALTER TABLE users DROP COLUMN "is_admin"'); + $pdo->exec('ALTER TABLE users DROP COLUMN "is_project_admin"'); +} + +function version_76(PDO $pdo) +{ + $pdo->exec(" + CREATE TABLE project_has_groups ( + group_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + role VARCHAR(25) NOT NULL, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE(group_id, project_id) + ) + "); + + $pdo->exec("ALTER TABLE project_has_users ADD COLUMN role VARCHAR(25) NOT NULL DEFAULT '".Role::PROJECT_VIEWER."'"); + + $rq = $pdo->prepare('SELECT * FROM project_has_users'); + $rq->execute(); + $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array(); + + $rq = $pdo->prepare('UPDATE project_has_users SET "role"=? WHERE "id"=?'); + + foreach ($rows as $row) { + $rq->execute(array( + $row['is_owner'] == 1 ? Role::PROJECT_MANAGER : Role::PROJECT_MEMBER, + $row['id'], + )); + } + + $pdo->exec('ALTER TABLE project_has_users DROP COLUMN "is_owner"'); + $pdo->exec('ALTER TABLE project_has_users DROP COLUMN "id"'); +} function version_75(PDO $pdo) { diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php index f0510cff..534c3f3a 100644 --- a/app/Schema/Sqlite.php +++ b/app/Schema/Sqlite.php @@ -3,9 +3,33 @@ namespace Schema; use Kanboard\Core\Security\Token; +use Kanboard\Core\Security\Role; use PDO; -const VERSION = 89; +const VERSION = 91; + +function version_91(PDO $pdo) +{ + $pdo->exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT '".Role::APP_USER."'"); + + $rq = $pdo->prepare('SELECT * FROM users'); + $rq->execute(); + $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array(); + + $rq = $pdo->prepare('UPDATE users SET "role"=? WHERE "id"=?'); + + foreach ($rows as $row) { + $role = Role::APP_USER; + + if ($row['is_admin'] == 1) { + $role = Role::APP_ADMIN; + } else if ($row['is_project_admin']) { + $role = Role::APP_MANAGER; + } + + $rq->execute(array($role, $row['id'])); + } +} function version_90(PDO $pdo) { @@ -19,6 +43,21 @@ function version_90(PDO $pdo) UNIQUE(group_id, project_id) ) "); + + $pdo->exec("ALTER TABLE project_has_users ADD COLUMN role TEXT NOT NULL DEFAULT '".Role::PROJECT_VIEWER."'"); + + $rq = $pdo->prepare('SELECT * FROM project_has_users'); + $rq->execute(); + $rows = $rq->fetchAll(PDO::FETCH_ASSOC) ?: array(); + + $rq = $pdo->prepare('UPDATE project_has_users SET "role"=? WHERE "id"=?'); + + foreach ($rows as $row) { + $rq->execute(array( + $row['is_owner'] == 1 ? Role::PROJECT_MANAGER : Role::PROJECT_MEMBER, + $row['id'], + )); + } } function version_89(PDO $pdo) @@ -1004,7 +1043,6 @@ function version_7(PDO $pdo) { $pdo->exec(" CREATE TABLE project_has_users ( - id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL, user_id INTEGER NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, diff --git a/app/ServiceProvider/AuthenticationProvider.php b/app/ServiceProvider/AuthenticationProvider.php new file mode 100644 index 00000000..8600d96e --- /dev/null +++ b/app/ServiceProvider/AuthenticationProvider.php @@ -0,0 +1,149 @@ +register(new TotpAuth($container)); + $container['authenticationManager']->register(new RememberMeAuth($container)); + $container['authenticationManager']->register(new DatabaseAuth($container)); + + if (REVERSE_PROXY_AUTH) { + $container['authenticationManager']->register(new ReverseProxyAuth($container)); + } + + if (LDAP_AUTH) { + $container['authenticationManager']->register(new LdapAuth($container)); + } + + if (GITLAB_AUTH) { + $container['authenticationManager']->register(new GitlabAuth($container)); + } + + if (GITHUB_AUTH) { + $container['authenticationManager']->register(new GithubAuth($container)); + } + + if (GOOGLE_AUTH) { + $container['authenticationManager']->register(new GoogleAuth($container)); + } + + $container['projectAccessMap'] = $this->getProjectAccessMap(); + $container['applicationAccessMap'] = $this->getApplicationAccessMap(); + + $container['projectAuthorization'] = new Authorization($container['projectAccessMap']); + $container['applicationAuthorization'] = new Authorization($container['applicationAccessMap']); + + return $container; + } + + /** + * Get ACL for projects + * + * @access public + * @return AccessMap + */ + public function getProjectAccessMap() + { + $acl = new AccessMap; + $acl->setDefaultRole(Role::PROJECT_VIEWER); + $acl->setRoleHierarchy(Role::PROJECT_MANAGER, array(Role::PROJECT_MEMBER, Role::PROJECT_VIEWER)); + $acl->setRoleHierarchy(Role::PROJECT_MEMBER, array(Role::PROJECT_VIEWER)); + + $acl->add('Action', '*', Role::PROJECT_MANAGER); + $acl->add('Analytic', '*', Role::PROJECT_MANAGER); + $acl->add('Board', 'save', Role::PROJECT_MEMBER); + $acl->add('BoardPopover', '*', Role::PROJECT_MEMBER); + $acl->add('Calendar', 'save', Role::PROJECT_MEMBER); + $acl->add('Category', '*', Role::PROJECT_MANAGER); + $acl->add('Column', '*', Role::PROJECT_MANAGER); + $acl->add('Comment', '*', Role::PROJECT_MEMBER); + $acl->add('Customfilter', '*', Role::PROJECT_MEMBER); + $acl->add('Export', '*', Role::PROJECT_MANAGER); + $acl->add('File', array('screenshot', 'create', 'save', 'remove', 'confirm'), Role::PROJECT_MEMBER); + $acl->add('Gantt', '*', Role::PROJECT_MANAGER); + $acl->add('Project', array('share', 'integrations', 'notifications', 'edit', 'update', 'duplicate', 'disable', 'enable', 'remove'), Role::PROJECT_MANAGER); + $acl->add('ProjectPermission', '*', Role::PROJECT_MANAGER); + $acl->add('Projectuser', '*', Role::PROJECT_MANAGER); + $acl->add('Subtask', '*', Role::PROJECT_MEMBER); + $acl->add('Swimlane', '*', Role::PROJECT_MANAGER); + $acl->add('Task', 'remove', Role::PROJECT_MEMBER); + $acl->add('Taskcreation', '*', Role::PROJECT_MEMBER); + $acl->add('Taskduplication', '*', Role::PROJECT_MEMBER); + $acl->add('TaskImport', '*', Role::PROJECT_MANAGER); + $acl->add('Tasklink', '*', Role::PROJECT_MEMBER); + $acl->add('Taskmodification', '*', Role::PROJECT_MEMBER); + $acl->add('Taskstatus', '*', Role::PROJECT_MEMBER); + $acl->add('Timer', '*', Role::PROJECT_MEMBER); + + return $acl; + } + + /** + * Get ACL for the application + * + * @access public + * @return AccessMap + */ + public function getApplicationAccessMap() + { + $acl = new AccessMap; + $acl->setDefaultRole(Role::APP_USER); + $acl->setRoleHierarchy(Role::APP_ADMIN, array(Role::APP_MANAGER, Role::APP_USER, Role::APP_PUBLIC)); + $acl->setRoleHierarchy(Role::APP_MANAGER, array(Role::APP_USER, Role::APP_PUBLIC)); + $acl->setRoleHierarchy(Role::APP_USER, array(Role::APP_PUBLIC)); + + $acl->add('Oauth', array('google', 'github', 'gitlab'), Role::APP_PUBLIC); + $acl->add('Auth', array('login', 'check', 'captcha'), Role::APP_PUBLIC); + $acl->add('Webhook', '*', Role::APP_PUBLIC); + $acl->add('Task', 'readonly', Role::APP_PUBLIC); + $acl->add('Board', 'readonly', Role::APP_PUBLIC); + $acl->add('Ical', '*', Role::APP_PUBLIC); + $acl->add('Feed', '*', Role::APP_PUBLIC); + + $acl->add('Config', '*', Role::APP_ADMIN); + $acl->add('Currency', '*', Role::APP_ADMIN); + $acl->add('Gantt', '*', Role::APP_MANAGER); + $acl->add('Group', '*', Role::APP_ADMIN); + $acl->add('Link', '*', Role::APP_ADMIN); + $acl->add('Project', array('users', 'allowEverybody', 'allow', 'role', 'revoke', 'create'), Role::APP_MANAGER); + $acl->add('ProjectPermission', '*', Role::APP_MANAGER); + $acl->add('Projectuser', '*', Role::APP_MANAGER); + $acl->add('Twofactor', 'disable', Role::APP_ADMIN); + $acl->add('UserImport', '*', Role::APP_ADMIN); + $acl->add('User', array('index', 'create', 'save', 'authentication', 'remove'), Role::APP_ADMIN); + + return $acl; + } +} diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index 9ec81116..76fe70f6 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -5,23 +5,17 @@ namespace Kanboard\ServiceProvider; use Pimple\Container; use Pimple\ServiceProviderInterface; use League\HTMLToMarkdown\HtmlConverter; -use Kanboard\Core\Plugin\Loader; use Kanboard\Core\Mail\Client as EmailClient; use Kanboard\Core\ObjectStorage\FileStorage; use Kanboard\Core\Paginator; -use Kanboard\Core\OAuth2; +use Kanboard\Core\Http\OAuth2; use Kanboard\Core\Tool; use Kanboard\Core\Http\Client as HttpClient; -use Kanboard\Model\UserNotificationType; -use Kanboard\Model\ProjectNotificationType; -use Kanboard\Notification\Mail as MailNotification; -use Kanboard\Notification\Web as WebNotification; class ClassProvider implements ServiceProviderInterface { private $classes = array( 'Model' => array( - 'Acl', 'Action', 'Authentication', 'Board', @@ -47,6 +41,9 @@ class ClassProvider implements ServiceProviderInterface 'ProjectPermission', 'ProjectNotification', 'ProjectMetadata', + 'ProjectGroupRole', + 'ProjectUserRole', + 'RememberMeSession', 'Subtask', 'SubtaskExport', 'SubtaskTimeTracking', @@ -69,7 +66,7 @@ class ClassProvider implements ServiceProviderInterface 'Transition', 'User', 'UserImport', - 'UserSession', + 'UserLocking', 'UserNotification', 'UserNotificationType', 'UserNotificationFilter', @@ -82,6 +79,8 @@ class ClassProvider implements ServiceProviderInterface 'TaskFilterCalendarFormatter', 'TaskFilterICalendarFormatter', 'ProjectGanttFormatter', + 'UserFilterAutoCompleteFormatter', + 'GroupAutoCompleteFormatter', ), 'Core' => array( 'DateParser', @@ -92,7 +91,7 @@ class ClassProvider implements ServiceProviderInterface 'Core\Http' => array( 'Request', 'Response', - 'Router', + 'RememberMeCookie', ), 'Core\Cache' => array( 'MemoryCache', @@ -102,6 +101,13 @@ class ClassProvider implements ServiceProviderInterface ), 'Core\Security' => array( 'Token', + 'Role', + ), + 'Core\User' => array( + 'GroupSync', + 'UserSync', + 'UserSession', + 'UserProfile', ), 'Integration' => array( 'BitbucketWebhook', @@ -142,22 +148,6 @@ class ClassProvider implements ServiceProviderInterface return $mailer; }; - $container['userNotificationType'] = function ($container) { - $type = new UserNotificationType($container); - $type->setType(MailNotification::TYPE, t('Email'), '\Kanboard\Notification\Mail'); - $type->setType(WebNotification::TYPE, t('Web'), '\Kanboard\Notification\Web'); - return $type; - }; - - $container['projectNotificationType'] = function ($container) { - $type = new ProjectNotificationType($container); - $type->setType('webhook', 'Webhook', '\Kanboard\Notification\Webhook', true); - $type->setType('activity_stream', 'ActivityStream', '\Kanboard\Notification\ActivityStream', true); - return $type; - }; - - $container['pluginLoader'] = new Loader($container); - $container['cspRules'] = array('style-src' => "'self' 'unsafe-inline'", 'img-src' => '* data:'); return $container; diff --git a/app/ServiceProvider/GroupProvider.php b/app/ServiceProvider/GroupProvider.php new file mode 100644 index 00000000..dff4b23a --- /dev/null +++ b/app/ServiceProvider/GroupProvider.php @@ -0,0 +1,37 @@ +register(new DatabaseBackendGroupProvider($container)); + + if (LDAP_AUTH && LDAP_GROUP_PROVIDER) { + $container['groupManager']->register(new LdapBackendGroupProvider($container)); + } + + return $container; + } +} diff --git a/app/ServiceProvider/NotificationProvider.php b/app/ServiceProvider/NotificationProvider.php new file mode 100644 index 00000000..83daf65d --- /dev/null +++ b/app/ServiceProvider/NotificationProvider.php @@ -0,0 +1,45 @@ +setType(MailNotification::TYPE, t('Email'), '\Kanboard\Notification\Mail'); + $type->setType(WebNotification::TYPE, t('Web'), '\Kanboard\Notification\Web'); + return $type; + }; + + $container['projectNotificationType'] = function ($container) { + $type = new ProjectNotificationType($container); + $type->setType('webhook', 'Webhook', '\Kanboard\Notification\Webhook', true); + $type->setType('activity_stream', 'ActivityStream', '\Kanboard\Notification\ActivityStream', true); + return $type; + }; + + return $container; + } +} diff --git a/app/ServiceProvider/PluginProvider.php b/app/ServiceProvider/PluginProvider.php new file mode 100644 index 00000000..d2f1666b --- /dev/null +++ b/app/ServiceProvider/PluginProvider.php @@ -0,0 +1,31 @@ +scan(); + + return $container; + } +} diff --git a/app/ServiceProvider/RouteProvider.php b/app/ServiceProvider/RouteProvider.php new file mode 100644 index 00000000..60ed161c --- /dev/null +++ b/app/ServiceProvider/RouteProvider.php @@ -0,0 +1,151 @@ +addRoute('dashboard', 'app', 'index'); + $container['router']->addRoute('dashboard/:user_id', 'app', 'index', array('user_id')); + $container['router']->addRoute('dashboard/:user_id/projects', 'app', 'projects', array('user_id')); + $container['router']->addRoute('dashboard/:user_id/tasks', 'app', 'tasks', array('user_id')); + $container['router']->addRoute('dashboard/:user_id/subtasks', 'app', 'subtasks', array('user_id')); + $container['router']->addRoute('dashboard/:user_id/calendar', 'app', 'calendar', array('user_id')); + $container['router']->addRoute('dashboard/:user_id/activity', 'app', 'activity', array('user_id')); + + // Search routes + $container['router']->addRoute('search', 'search', 'index'); + $container['router']->addRoute('search/:search', 'search', 'index', array('search')); + + // Project routes + $container['router']->addRoute('projects', 'project', 'index'); + $container['router']->addRoute('project/create', 'project', 'create'); + $container['router']->addRoute('project/create/:private', 'project', 'create', array('private')); + $container['router']->addRoute('project/:project_id', 'project', 'show', array('project_id')); + $container['router']->addRoute('p/:project_id', 'project', 'show', array('project_id')); + $container['router']->addRoute('project/:project_id/customer-filter', 'customfilter', 'index', array('project_id')); + $container['router']->addRoute('project/:project_id/share', 'project', 'share', array('project_id')); + $container['router']->addRoute('project/:project_id/notifications', 'project', 'notifications', array('project_id')); + $container['router']->addRoute('project/:project_id/edit', 'project', 'edit', array('project_id')); + $container['router']->addRoute('project/:project_id/integrations', 'project', 'integrations', array('project_id')); + $container['router']->addRoute('project/:project_id/duplicate', 'project', 'duplicate', array('project_id')); + $container['router']->addRoute('project/:project_id/remove', 'project', 'remove', array('project_id')); + $container['router']->addRoute('project/:project_id/disable', 'project', 'disable', array('project_id')); + $container['router']->addRoute('project/:project_id/enable', 'project', 'enable', array('project_id')); + $container['router']->addRoute('project/:project_id/permissions', 'ProjectPermission', 'index', array('project_id')); + $container['router']->addRoute('project/:project_id/import', 'taskImport', 'step1', array('project_id')); + + // Action routes + $container['router']->addRoute('project/:project_id/actions', 'action', 'index', array('project_id')); + $container['router']->addRoute('project/:project_id/action/:action_id/confirm', 'action', 'confirm', array('project_id', 'action_id')); + + // Column routes + $container['router']->addRoute('project/:project_id/columns', 'column', 'index', array('project_id')); + $container['router']->addRoute('project/:project_id/column/:column_id/edit', 'column', 'edit', array('project_id', 'column_id')); + $container['router']->addRoute('project/:project_id/column/:column_id/confirm', 'column', 'confirm', array('project_id', 'column_id')); + $container['router']->addRoute('project/:project_id/column/:column_id/move/:direction', 'column', 'move', array('project_id', 'column_id', 'direction')); + + // Swimlane routes + $container['router']->addRoute('project/:project_id/swimlanes', 'swimlane', 'index', array('project_id')); + $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/edit', 'swimlane', 'edit', array('project_id', 'swimlane_id')); + $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/confirm', 'swimlane', 'confirm', array('project_id', 'swimlane_id')); + $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/disable', 'swimlane', 'disable', array('project_id', 'swimlane_id')); + $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/enable', 'swimlane', 'enable', array('project_id', 'swimlane_id')); + $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/up', 'swimlane', 'moveup', array('project_id', 'swimlane_id')); + $container['router']->addRoute('project/:project_id/swimlane/:swimlane_id/down', 'swimlane', 'movedown', array('project_id', 'swimlane_id')); + + // Category routes + $container['router']->addRoute('project/:project_id/categories', 'category', 'index', array('project_id')); + $container['router']->addRoute('project/:project_id/category/:category_id/edit', 'category', 'edit', array('project_id', 'category_id')); + $container['router']->addRoute('project/:project_id/category/:category_id/confirm', 'category', 'confirm', array('project_id', 'category_id')); + + // Task routes + $container['router']->addRoute('project/:project_id/task/:task_id', 'task', 'show', array('project_id', 'task_id')); + $container['router']->addRoute('t/:task_id', 'task', 'show', array('task_id')); + $container['router']->addRoute('public/task/:task_id/:token', 'task', 'readonly', array('task_id', 'token')); + + $container['router']->addRoute('project/:project_id/task/:task_id/activity', 'activity', 'task', array('project_id', 'task_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/screenshot', 'file', 'screenshot', array('project_id', 'task_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/upload', 'file', 'create', array('project_id', 'task_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/comment', 'comment', 'create', array('project_id', 'task_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/link', 'tasklink', 'create', array('project_id', 'task_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/transitions', 'task', 'transitions', array('project_id', 'task_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/analytics', 'task', 'analytics', array('project_id', 'task_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/remove', 'task', 'remove', array('project_id', 'task_id')); + + $container['router']->addRoute('project/:project_id/task/:task_id/edit', 'taskmodification', 'edit', array('project_id', 'task_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/description', 'taskmodification', 'description', array('project_id', 'task_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/recurrence', 'taskmodification', 'recurrence', array('project_id', 'task_id')); + + $container['router']->addRoute('project/:project_id/task/:task_id/close', 'taskstatus', 'close', array('task_id', 'project_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/open', 'taskstatus', 'open', array('task_id', 'project_id')); + + $container['router']->addRoute('project/:project_id/task/:task_id/duplicate', 'taskduplication', 'duplicate', array('task_id', 'project_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/copy', 'taskduplication', 'copy', array('task_id', 'project_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/copy/:dst_project_id', 'taskduplication', 'copy', array('task_id', 'project_id', 'dst_project_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/move', 'taskduplication', 'move', array('task_id', 'project_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/move/:dst_project_id', 'taskduplication', 'move', array('task_id', 'project_id', 'dst_project_id')); + + // Board routes + $container['router']->addRoute('board/:project_id', 'board', 'show', array('project_id')); + $container['router']->addRoute('b/:project_id', 'board', 'show', array('project_id')); + $container['router']->addRoute('public/board/:token', 'board', 'readonly', array('token')); + + // Calendar routes + $container['router']->addRoute('calendar/:project_id', 'calendar', 'show', array('project_id')); + $container['router']->addRoute('c/:project_id', 'calendar', 'show', array('project_id')); + + // Listing routes + $container['router']->addRoute('list/:project_id', 'listing', 'show', array('project_id')); + $container['router']->addRoute('l/:project_id', 'listing', 'show', array('project_id')); + + // Gantt routes + $container['router']->addRoute('gantt/:project_id', 'gantt', 'project', array('project_id')); + $container['router']->addRoute('gantt/:project_id/sort/:sorting', 'gantt', 'project', array('project_id', 'sorting')); + + // Subtask routes + $container['router']->addRoute('project/:project_id/task/:task_id/subtask/create', 'subtask', 'create', array('project_id', 'task_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/subtask/:subtask_id/remove', 'subtask', 'confirm', array('project_id', 'task_id', 'subtask_id')); + $container['router']->addRoute('project/:project_id/task/:task_id/subtask/:subtask_id/edit', 'subtask', 'edit', array('project_id', 'task_id', 'subtask_id')); + + // Feed routes + $container['router']->addRoute('feed/project/:token', 'feed', 'project', array('token')); + $container['router']->addRoute('feed/user/:token', 'feed', 'user', array('token')); + + // Ical routes + $container['router']->addRoute('ical/project/:token', 'ical', 'project', array('token')); + $container['router']->addRoute('ical/user/:token', 'ical', 'user', array('token')); + + // Auth routes + $container['router']->addRoute('oauth/google', 'oauth', 'google'); + $container['router']->addRoute('oauth/github', 'oauth', 'github'); + $container['router']->addRoute('oauth/gitlab', 'oauth', 'gitlab'); + $container['router']->addRoute('login', 'auth', 'login'); + $container['router']->addRoute('logout', 'auth', 'logout'); + } + + return $container; + } +} diff --git a/app/ServiceProvider/SessionProvider.php b/app/ServiceProvider/SessionProvider.php index 414d9578..0999d531 100644 --- a/app/ServiceProvider/SessionProvider.php +++ b/app/ServiceProvider/SessionProvider.php @@ -8,8 +8,21 @@ use Kanboard\Core\Session\SessionManager; use Kanboard\Core\Session\SessionStorage; use Kanboard\Core\Session\FlashMessage; +/** + * Session Provider + * + * @package serviceProvider + * @author Frederic Guillot + */ class SessionProvider implements ServiceProviderInterface { + /** + * Register providers + * + * @access public + * @param \Pimple\Container $container + * @return \Pimple\Container + */ public function register(Container $container) { $container['sessionStorage'] = function() { diff --git a/app/Subscriber/AuthSubscriber.php b/app/Subscriber/AuthSubscriber.php index 77a39942..a0e0be63 100644 --- a/app/Subscriber/AuthSubscriber.php +++ b/app/Subscriber/AuthSubscriber.php @@ -2,26 +2,100 @@ namespace Kanboard\Subscriber; -use Kanboard\Core\Http\Request; -use Kanboard\Event\AuthEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Kanboard\Core\Base; +use Kanboard\Core\Security\AuthenticationManager; +use Kanboard\Core\Session\SessionManager; +use Kanboard\Event\AuthSuccessEvent; +use Kanboard\Event\AuthFailureEvent; -class AuthSubscriber extends \Kanboard\Core\Base implements EventSubscriberInterface +/** + * Authentication Subscriber + * + * @package subscriber + * @author Frederic Guillot + */ +class AuthSubscriber extends Base implements EventSubscriberInterface { + /** + * Get event listeners + * + * @static + * @access public + * @return array + */ public static function getSubscribedEvents() { return array( - 'auth.success' => array('onSuccess', 0), + AuthenticationManager::EVENT_SUCCESS => 'afterLogin', + AuthenticationManager::EVENT_FAILURE => 'onLoginFailure', + SessionManager::EVENT_DESTROY => 'afterLogout', ); } - public function onSuccess(AuthEvent $event) + /** + * After Login callback + * + * @access public + * @param AuthSuccessEvent $event + */ + public function afterLogin(AuthSuccessEvent $event) { + $userAgent = $this->request->getUserAgent(); + $ipAddress = $this->request->getIpAddress(); + + $this->userLocking->resetFailedLogin($this->userSession->getUsername()); + $this->lastLogin->create( $event->getAuthType(), - $event->getUserId(), - Request::getIpAddress(), - Request::getUserAgent() + $this->userSession->getId(), + $ipAddress, + $userAgent ); + + $this->sessionStorage->hasSubtaskInProgress = $this->subtask->hasSubtaskInProgress($this->userSession->getId()); + + if (isset($this->sessionStorage->hasRememberMe) && $this->sessionStorage->hasRememberMe) { + $session = $this->rememberMeSession->create($this->userSession->getId(), $ipAddress, $userAgent); + $this->rememberMeCookie->write($session['token'], $session['sequence'], $session['expiration']); + } + } + + /** + * Destroy RememberMe session on logout + * + * @access public + */ + public function afterLogout() + { + $credentials = $this->rememberMeCookie->read(); + + if ($credentials !== false) { + $session = $this->rememberMeSession->find($credentials['token'], $credentials['sequence']); + + if (! empty($session)) { + $this->rememberMeSession->remove($session['id']); + } + + $this->rememberMeCookie->remove(); + } + } + + /** + * Increment failed login counter + * + * @access public + */ + public function onLoginFailure(AuthFailureEvent $event) + { + $username = $event->getUsername(); + + if (! empty($username)) { + $this->userLocking->incrementFailedLogin($username); + + if ($this->userLocking->getFailedLogin($username) > BRUTEFORCE_LOCKDOWN) { + $this->userLocking->lock($username, BRUTEFORCE_LOCKDOWN_DURATION); + } + } } } diff --git a/app/Subscriber/BootstrapSubscriber.php b/app/Subscriber/BootstrapSubscriber.php index 25b919f7..cc0bc06d 100644 --- a/app/Subscriber/BootstrapSubscriber.php +++ b/app/Subscriber/BootstrapSubscriber.php @@ -9,9 +9,7 @@ class BootstrapSubscriber extends \Kanboard\Core\Base implements EventSubscriber public static function getSubscribedEvents() { return array( - 'session.bootstrap' => array('setup', 0), - 'api.bootstrap' => array('setup', 0), - 'console.bootstrap' => array('setup', 0), + 'app.bootstrap' => array('setup', 0), ); } @@ -20,4 +18,18 @@ class BootstrapSubscriber extends \Kanboard\Core\Base implements EventSubscriber $this->config->setupTranslations(); $this->config->setupTimezone(); } + + public function __destruct() + { + if (DEBUG) { + foreach ($this->db->getLogMessages() as $message) { + $this->logger->debug($message); + } + + $this->logger->debug('SQL_QUERIES={nb}', array('nb' => $this->container['db']->nbQueries)); + $this->logger->debug('RENDERING={time}', array('time' => microtime(true) - $this->request->getStartTime())); + $this->logger->debug('MEMORY='.$this->helper->text->bytes(memory_get_usage())); + $this->logger->debug('URI='.$this->request->getUri()); + } + } } diff --git a/app/Template/activity/project.php b/app/Template/activity/project.php index bc585212..34be06f5 100644 --- a/app/Template/activity/project.php +++ b/app/Template/activity/project.php @@ -19,7 +19,7 @@ url->link(t('Back to the calendar'), 'calendar', 'show', array('project_id' => $project['id'])) ?> - user->isProjectManagementAllowed($project['id'])): ?> + user->hasProjectAccess('project', 'edit', $project['id'])): ?>
  • url->link(t('Project settings'), 'project', 'show', array('project_id' => $project['id'])) ?> diff --git a/app/Template/analytic/layout.php b/app/Template/analytic/layout.php index fd2090ae..3bb6ff6e 100644 --- a/app/Template/analytic/layout.php +++ b/app/Template/analytic/layout.php @@ -19,7 +19,7 @@ url->link(t('Back to the calendar'), 'calendar', 'show', array('project_id' => $project['id'])) ?>
  • - user->isProjectManagementAllowed($project['id'])): ?> + user->hasProjectAccess('project', 'edit', $project['id'])): ?>
  • url->link(t('Project settings'), 'project', 'show', array('project_id' => $project['id'])) ?> diff --git a/app/Template/app/layout.php b/app/Template/app/layout.php index 4f82121e..ad1d5a9e 100644 --- a/app/Template/app/layout.php +++ b/app/Template/app/layout.php @@ -1,7 +1,7 @@