Compare commits

..
10 Commits
Author SHA1 Message Date
dualfroz 68ee022ee8 Describe this repository 2026-10-22 21:04:26 +02:00
dualfroz 6a87749efd fix(ical): do not expose user email addresses in calendar feeds
The project and user iCal feeds only need a token and they put the
email address of the task assignee and creator in ATTENDEE and
ORGANIZER. Always use the username@kanboard.local address that was
already the fallback for users without an email.

This also fixes ORGANIZER for creators without an email, which was
rendered as a bare MAILTO: because of operator precedence.
2026-10-03 15:14:00 +02:00
upstream 01fa57e17a Upstream kanboard/kanboard state of the touched files before 58b9707712 2026-10-03 01:30:33 +02:00
dualfroz 43d1e8b58c fix(api): remove author email addresses from comments and activity
getComment, getAllComments, getProjectActivity, getProjectActivities and
getMyActivityStream returned the email address of the comment or event
author to any project member. Comment events also carry it in their
stored comment data.

The models keep the column since the web UI needs it for avatars, the
procedures drop it before answering.

Fixes #5876
2026-10-03 17:19:19 +02:00
upstream 94a9f89416 Upstream kanboard/kanboard state of the touched files before e6a5d284be 2026-10-02 16:27:40 +02:00
dualfroz cce89ba4a2 fix(http): normalize the host before the private network check
parse_url() keeps the brackets around an IPv6 literal, so "[::1]" or
"[::ffff:127.0.0.1]" never matched an address and isPrivateURL() let the
request through. A host that does not resolve was allowed for the same
reason, while cURL still connects to forms like "0x7f000001" or
"127.0.0.1." on its own.

Strip the brackets and the trailing dot, check IP literals directly, check
every resolved address, and treat a host without any address as private.
IPv4-mapped IPv6 addresses are checked through their IPv4 part, since
FILTER_FLAG_NO_RES_RANGE does not classify them the same way on every PHP
version.

Fixes #5858
Fixes #5871
2026-10-03 17:43:14 +02:00
upstream 5da0d865e0 Upstream kanboard/kanboard state of the touched files before 0131fcfe61 2026-10-02 16:23:14 +02:00
dualfroz bb891e182e fix(security): escape the project name in the analytic modal title
LayoutHelper::analytic() prefixes the title with the raw project name, and
the AJAX branch of analytic/layout.php printed that title as is, so a
project name containing markup was injected into the analytic modals.

Escape it with $this->text->e() like the other layouts do. It does not
double-encode, so the ">" separator still renders as ">".
2026-10-03 15:58:07 +02:00
upstream 392b2d737b Upstream kanboard/kanboard state of the touched files before 2e3f570f88 2026-10-02 16:19:49 +02:00
dualfroz 23bbaa8c60 fix(ical): only accept known columns as the calendar start date
The calendar_project_tasks and calendar_user_tasks settings were used as a
column name in the iCal query, and escapeIdentifier() leaves values with a
space or a dot untouched. A stored value like "1 OR 1=1 OR 1" made the public
project feed return tasks from every project.

Only date_creation and date_started are accepted now, anything else falls
back to date_started. The currency form also stops saving keys other than
application_currency, which is how the value could be written in the first
place.

Fixes #5904
2026-10-03 16:44:04 +02:00
18 changed files with 1964 additions and 3 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# Contributions to kanboard/kanboard
Kanban project management software
This repository holds only my commits to the upstream project, with their original author, date and diff.
Each of my commits is preceded by a snapshot of the upstream files it touched, so every diff shows exactly what I changed.
## Merged pull requests
- [fix(ical): only accept known columns as the calendar start date](https://github.com/kanboard/kanboard/pull/5914) (2026-10-11)
- [fix(security): escape the project name in the analytic modal title](https://github.com/kanboard/kanboard/pull/5913) (2026-10-11)
- [fix(session): scope the session cookie to the directory without a trailing slash](https://github.com/kanboard/kanboard/pull/5900) (2026-10-02)
- [fix(api): return a full batch response when one request is forbidden](https://github.com/kanboard/kanboard/pull/5899) (2026-09-11)
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace Kanboard\Api\Procedure;
use Kanboard\Api\Authorization\ProcedureAuthorization;
use Kanboard\Api\Authorization\UserAuthorization;
use Kanboard\Core\Base;
use ReflectionClass;
/**
* Base class
*
* @package Kanboard\Api\Procedure
* @author Frederic Guillot
*/
abstract class BaseProcedure extends Base
{
public function beforeProcedure($procedure)
{
ProcedureAuthorization::getInstance($this->container)->check($procedure);
UserAuthorization::getInstance($this->container)->check($this->getClassName(), $procedure);
}
protected function filterValues(array $values)
{
foreach ($values as $key => $value) {
if (is_null($value)) {
unset($values[$key]);
}
}
return $values;
}
protected function filterUser($user)
{
return is_array($user) ? $this->userModel->removePrivateColumns($user) : $user;
}
protected function filterUsers($users)
{
return is_array($users) ? array_map(array($this, 'filterUser'), $users) : $users;
}
protected function filterComment($comment)
{
if (is_array($comment)) {
unset($comment['email']);
}
return $comment;
}
protected function filterComments($comments)
{
return is_array($comments) ? array_map(array($this, 'filterComment'), $comments) : $comments;
}
protected function filterEvent(array $event)
{
unset($event['email']);
if (isset($event['comment'])) {
$event['comment'] = $this->filterComment($event['comment']);
}
return $event;
}
protected function filterEvents(array $events)
{
return array_map(array($this, 'filterEvent'), $events);
}
protected function getClassName()
{
$reflection = new ReflectionClass(get_called_class());
return $reflection->getShortName();
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace Kanboard\Api\Procedure;
use Kanboard\Api\Authorization\CommentAuthorization;
use Kanboard\Api\Authorization\TaskAuthorization;
use Kanboard\Core\Security\Role;
/**
* Comment API controller
*
* @package Kanboard\Api\Procedure
* @author Frederic Guillot
*/
class CommentProcedure extends BaseProcedure
{
public function getComment($comment_id)
{
CommentAuthorization::getInstance($this->container)->check($this->getClassName(), 'getComment', $comment_id);
return $this->filterComment($this->commentModel->getById($comment_id));
}
public function getAllComments($task_id)
{
TaskAuthorization::getInstance($this->container)->check($this->getClassName(), 'getAllComments', $task_id);
$comments = $this->commentModel->getAll($task_id);
$filteredComments = [];
$userRole = $this->userSession->getRole();
foreach ($comments as $comment) {
if ($userRole === Role::APP_MANAGER && $comment['visibility'] === Role::APP_ADMIN) {
continue;
}
if ($userRole === Role::APP_USER && $comment['visibility'] !== Role::APP_USER) {
continue;
}
$filteredComments[] = $comment;
}
return $this->filterComments($filteredComments);
}
public function removeComment($comment_id)
{
CommentAuthorization::getInstance($this->container)->check($this->getClassName(), 'removeComment', $comment_id);
if ($this->userSession->isLogged()) {
$loggedUserID = $this->userSession->getId();
$comment = $this->commentModel->getById($comment_id);
if ($comment['user_id'] != $loggedUserID) {
return false;
}
}
return $this->commentModel->remove($comment_id);
}
public function createComment($task_id, $user_id, $content, $reference = '', $visibility = Role::APP_USER)
{
TaskAuthorization::getInstance($this->container)->check($this->getClassName(), 'createComment', $task_id);
if ($this->userSession->isLogged()) {
$loggedUserID = $this->userSession->getId();
if ($user_id != $loggedUserID) {
return false;
}
$userRole = $this->userSession->getRole();
if ($userRole === Role::APP_MANAGER && $visibility === Role::APP_ADMIN) {
return false;
}
if ($userRole === Role::APP_USER && $visibility !== Role::APP_USER) {
return false;
}
}
$values = array(
'task_id' => $task_id,
'user_id' => $user_id,
'comment' => $content,
'reference' => $reference,
'visibility' => $visibility,
);
list($valid, ) = $this->commentValidator->validateCreation($values);
return $valid ? $this->commentModel->create($values) : false;
}
public function updateComment($id, $content)
{
CommentAuthorization::getInstance($this->container)->check($this->getClassName(), 'updateComment', $id);
if ($this->userSession->isLogged()) {
$loggedUserID = $this->userSession->getId();
$comment = $this->commentModel->getById($id);
if ($comment['user_id'] != $loggedUserID) {
return false;
}
}
$values = array(
'id' => $id,
'comment' => $content,
);
list($valid, ) = $this->commentValidator->validateModification($values);
return $valid && $this->commentModel->update($values);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Kanboard\Api\Procedure;
/**
* Me API controller
*
* @package Kanboard\Api\Procedure
* @author Frederic Guillot
*/
class MeProcedure extends BaseProcedure
{
public function getMe()
{
return session_get('user');
}
public function getMyDashboard()
{
$userId = $this->userSession->getId();
return $this->taskListSubtaskAssigneeFormatter
->withQuery($this->taskFinderModel->getUserQuery($userId))
->withUserId($userId)
->format();
}
public function getMyActivityStream()
{
$project_ids = $this->projectPermissionModel->getActiveProjectIds($this->userSession->getId());
return $this->filterEvents($this->helper->projectActivity->getProjectsEvents($project_ids, 100));
}
public function createMyPrivateProject($name, $description = null)
{
if ($this->configModel->get('disable_private_project', 0) == 1) {
return false;
}
$values = array(
'name' => $name,
'description' => $description,
'is_private' => 1,
);
list($valid, ) = $this->projectValidator->validateCreation($values);
return $valid ? $this->projectModel->create($values, $this->userSession->getId(), true) : false;
}
public function getMyProjectsList()
{
return (object) $this->projectUserRoleModel->getProjectsByUser($this->userSession->getId());
}
public function getMyOverdueTasks()
{
return $this->taskFinderModel->getOverdueTasksByUser($this->userSession->getId());
}
public function getMyProjects()
{
$project_ids = $this->projectPermissionModel->getActiveProjectIds($this->userSession->getId());
$projects = $this->projectModel->getAllByIds($project_ids);
return $this->projectsApiFormatter->withProjects($projects)->format();
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace Kanboard\Api\Procedure;
use Kanboard\Api\Authorization\ProjectAuthorization;
/**
* Project API controller
*
* @package Kanboard\Api\Procedure
* @author Frederic Guillot
*/
class ProjectProcedure extends BaseProcedure
{
public function getProjectById($project_id)
{
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'getProjectById', $project_id);
$project = $this->projectModel->getById($project_id);
return $this->projectApiFormatter->withProject($project)->format();
}
public function getProjectByName($name)
{
$project = $this->projectModel->getByName($name);
if (empty($project)) {
return false;
}
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'getProjectByName', $project['id']);
return $this->projectApiFormatter->withProject($project)->format();
}
public function getProjectByIdentifier($identifier)
{
$project = $this->projectModel->getByIdentifier($identifier);
if (empty($project)) {
return false;
}
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'getProjectByIdentifier', $project['id']);
return $this->projectApiFormatter->withProject($project)->format();
}
public function getProjectByEmail($email)
{
$project = $this->projectModel->getByEmail($email);
if (empty($project)) {
return false;
}
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'getProjectByEmail', $project['id']);
return $this->projectApiFormatter->withProject($project)->format();
}
public function getAllProjects()
{
return $this->projectsApiFormatter->withProjects($this->projectModel->getAll())->format();
}
public function removeProject($project_id)
{
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'removeProject', $project_id);
return $this->projectModel->remove($project_id);
}
public function enableProject($project_id)
{
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'enableProject', $project_id);
return $this->projectModel->enable($project_id);
}
public function disableProject($project_id)
{
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'disableProject', $project_id);
return $this->projectModel->disable($project_id);
}
public function enableProjectPublicAccess($project_id)
{
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'enableProjectPublicAccess', $project_id);
return $this->projectModel->enablePublicAccess($project_id);
}
public function disableProjectPublicAccess($project_id)
{
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'disableProjectPublicAccess', $project_id);
return $this->projectModel->disablePublicAccess($project_id);
}
public function getProjectActivities(array $project_ids)
{
foreach ($project_ids as $project_id) {
ProjectAuthorization::getInstance($this->container)
->check($this->getClassName(), 'getProjectActivities', $project_id);
}
return $this->filterEvents($this->helper->projectActivity->getProjectsEvents($project_ids));
}
public function getProjectActivity($project_id)
{
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'getProjectActivity', $project_id);
return $this->filterEvents($this->helper->projectActivity->getProjectEvents($project_id));
}
public function createProject($name, $description = null, $owner_id = 0, $identifier = null, $start_date = null, $end_date = null, $priority_default = null, $priority_start = null, $priority_end = null, $email = null)
{
if ($owner_id === 0 && $this->userSession->isLogged()) {
$owner_id = $this->userSession->getId();
}
$values = $this->filterValues(array(
'name' => $name,
'description' => $description,
'identifier' => $identifier,
'start_date' => $start_date,
'end_date' => $end_date,
'priority_default' => $priority_default,
'priority_start' => $priority_start,
'priority_end' => $priority_end,
'email' => $email,
));
list($valid, ) = $this->projectValidator->validateCreation($values);
return $valid ? $this->projectModel->create($values, $owner_id, $this->userSession->isLogged()) : false;
}
public function updateProject($project_id, $name = null, $description = null, $owner_id = null, $identifier = null, $start_date = null, $end_date = null, $priority_default = null, $priority_start = null, $priority_end = null, $email = null)
{
ProjectAuthorization::getInstance($this->container)->check($this->getClassName(), 'updateProject', $project_id);
$values = $this->filterValues(array(
'id' => $project_id,
'name' => $name,
'description' => $description,
'owner_id' => $owner_id,
'identifier' => $identifier,
'start_date' => $start_date,
'end_date' => $end_date,
'priority_default' => $priority_default,
'priority_start' => $priority_start,
'priority_end' => $priority_end,
'email' => $email,
));
list($valid, ) = $this->projectValidator->validateModification($values);
return $valid && $this->projectModel->update($values);
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ class CurrencyController extends BaseController
*/ */
public function update() public function update()
{ {
$values = $this->request->getValues(); $values = array_intersect_key($this->request->getValues(), array('application_currency' => ''));
if ($this->configModel->save($values)) { if ($this->configModel->save($values)) {
$this->flash->success(t('Settings saved successfully.')); $this->flash->success(t('Settings saved successfully.'));
+8 -2
View File
@@ -31,7 +31,7 @@ class ICalendarController extends BaseController
$startRange = strtotime('-2 months'); $startRange = strtotime('-2 months');
$endRange = strtotime('+6 months'); $endRange = strtotime('+6 months');
$startColumn = $this->configModel->get('calendar_user_tasks', 'date_started'); $startColumn = $this->getStartColumn('calendar_user_tasks');
$calendar = new iCalendar('Kanboard'); $calendar = new iCalendar('Kanboard');
$calendar->setName($user['name'] ?: $user['username']); $calendar->setName($user['name'] ?: $user['username']);
@@ -71,7 +71,7 @@ class ICalendarController extends BaseController
$startRange = strtotime('-2 months'); $startRange = strtotime('-2 months');
$endRange = strtotime('+6 months'); $endRange = strtotime('+6 months');
$startColumn = $this->configModel->get('calendar_project_tasks', 'date_started'); $startColumn = $this->getStartColumn('calendar_project_tasks');
$calendar = new iCalendar('Kanboard'); $calendar = new iCalendar('Kanboard');
$calendar->setName($project['name']); $calendar->setName($project['name']);
@@ -99,6 +99,12 @@ class ICalendarController extends BaseController
->format()); ->format());
} }
protected function getStartColumn($option)
{
$column = $this->configModel->get($option, 'date_started');
return in_array($column, array('date_creation', 'date_started'), true) ? $column : 'date_started';
}
protected function getConditionForTasksWithStartAndDueDate($start_time, $end_time, $start_column, $end_column) protected function getConditionForTasksWithStartAndDueDate($start_time, $end_time, $start_column, $end_column)
{ {
$start_time = (int) $start_time; $start_time = (int) $start_time;
+468
View File
@@ -0,0 +1,468 @@
<?php
namespace Kanboard\Core\Http;
use Kanboard\Core\Base;
use Kanboard\Job\HttpAsyncJob;
/**
* HTTP client
*
* @package Kanboard\Core\Http
* @author Frederic Guillot
*/
class Client extends Base
{
/**
* HTTP client user agent
*
* @var string
*/
const HTTP_USER_AGENT = 'Kanboard';
/**
* Send a GET HTTP request
*
* @access public
* @param string $url
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return string
*/
public function get($url, array $headers = [], $raiseForErrors = false, $followRedirects = true)
{
return $this->doRequest('GET', $url, '', $headers, $raiseForErrors, $followRedirects);
}
/**
* Send a GET HTTP request and parse JSON response
*
* @access public
* @param string $url
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return array
*/
public function getJson($url, array $headers = [], $raiseForErrors = false, $followRedirects = true)
{
$response = $this->doRequest('GET', $url, '', array_merge(['Accept: application/json'], $headers), $raiseForErrors, $followRedirects);
return json_decode($response, true) ?: [];
}
/**
* Send a POST HTTP request encoded in JSON
*
* @access public
* @param string $url
* @param array $data
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return string
*/
public function postJson($url, array $data, array $headers = [], $raiseForErrors = false, $followRedirects = true)
{
return $this->doRequest(
'POST',
$url,
json_encode($data),
array_merge(['Content-type: application/json'], $headers),
$raiseForErrors,
$followRedirects
);
}
/**
* Send a POST HTTP request encoded in JSON (Fire and forget)
*
* @access public
* @param string $url
* @param array $data
* @param string[] $headers
* @param bool $raiseForErrors
*/
public function postJsonAsync($url, array $data, array $headers = [], $raiseForErrors = false)
{
$this->queueManager->push(HttpAsyncJob::getInstance($this->container)->withParams(
'POST',
$url,
json_encode($data),
array_merge(['Content-type: application/json'], $headers),
$raiseForErrors
));
}
/**
* Send a POST HTTP request encoded in www-form-urlencoded
*
* @access public
* @param string $url
* @param array $data
* @param string[] $headers
* @param bool $raiseForErrors
* @return string
*/
public function postForm($url, array $data, array $headers = [], $raiseForErrors = false)
{
return $this->doRequest(
'POST',
$url,
http_build_query($data),
array_merge(['Content-type: application/x-www-form-urlencoded'], $headers),
$raiseForErrors
);
}
/**
* Send a POST HTTP request encoded in www-form-urlencoded (fire and forget)
*
* @access public
* @param string $url
* @param array $data
* @param string[] $headers
* @param bool $raiseForErrors
*/
public function postFormAsync($url, array $data, array $headers = [], $raiseForErrors = false)
{
$this->queueManager->push(HttpAsyncJob::getInstance($this->container)->withParams(
'POST',
$url,
http_build_query($data),
array_merge(['Content-type: application/x-www-form-urlencoded'], $headers),
$raiseForErrors
));
}
/**
* Make the HTTP request with cURL if detected, socket otherwise
*
* @access public
* @param string $method
* @param string $url
* @param string $content
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return string
*/
public function doRequest($method, $url, $content, array $headers, $raiseForErrors = false, $followRedirects = true)
{
$requestBody = '';
if (! empty($url)) {
if (function_exists('curl_version')) {
if (DEBUG) {
$this->logger->debug('HttpClient::doRequest: cURL detected');
}
$requestBody = $this->doRequestWithCurl($method, $url, $content, $headers, $raiseForErrors, $followRedirects);
} else {
if (DEBUG) {
$this->logger->debug('HttpClient::doRequest: using socket');
}
$requestBody = $this->doRequestWithSocket($method, $url, $content, $headers, $raiseForErrors, $followRedirects);
}
}
return $requestBody;
}
/**
* Make the HTTP request with socket
*
* @access private
* @param string $method
* @param string $url
* @param string $content
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return string
*/
private function doRequestWithSocket($method, $url, $content, array $headers, $raiseForErrors = false, $followRedirects = true)
{
$startTime = microtime(true);
$stream = @fopen(trim($url), 'r', false, stream_context_create($this->getContext($method, $content, $headers, $raiseForErrors, $followRedirects)));
if (! is_resource($stream)) {
$this->logger->error('HttpClient: request failed ('.$url.')');
if ($raiseForErrors) {
throw new ClientException('Unreachable URL: '.$url);
}
return '';
}
$body = stream_get_contents($stream);
$metadata = stream_get_meta_data($stream);
if ($raiseForErrors && array_key_exists('wrapper_data', $metadata)) {
$statusCode = $this->getStatusCode($metadata['wrapper_data']);
if ($statusCode >= 400) {
throw new InvalidStatusException('Request failed with status code '.$statusCode, $statusCode, $body);
}
}
if (DEBUG) {
$this->logger->debug('HttpClient: url='.$url);
$this->logger->debug('HttpClient: headers='.var_export($headers, true));
$this->logger->debug('HttpClient: payload='.$content);
$this->logger->debug('HttpClient: metadata='.var_export($metadata, true));
$this->logger->debug('HttpClient: body='.$body);
$this->logger->debug('HttpClient: executionTime='.(microtime(true) - $startTime));
}
return $body;
}
/**
* Make the HTTP request with cURL
*
* @access private
* @param string $method
* @param string $url
* @param string $content
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return string
*/
private function doRequestWithCurl($method, $url, $content, array $headers, $raiseForErrors = false, $followRedirects = true)
{
$startTime = microtime(true);
$curlSession = @curl_init();
curl_setopt($curlSession, CURLOPT_URL, trim($url));
curl_setopt($curlSession, CURLOPT_USERAGENT, self::HTTP_USER_AGENT);
curl_setopt($curlSession, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_setopt($curlSession, CURLOPT_TIMEOUT, HTTP_TIMEOUT);
curl_setopt($curlSession, CURLOPT_FORBID_REUSE, true);
curl_setopt($curlSession, CURLOPT_MAXREDIRS, $followRedirects ? HTTP_MAX_REDIRECTS : 0);
curl_setopt($curlSession, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curlSession, CURLOPT_FOLLOWLOCATION, $followRedirects);
if ('POST' === $method) {
curl_setopt($curlSession, CURLOPT_POST, true);
curl_setopt($curlSession, CURLOPT_POSTFIELDS, $content);
} elseif ('PUT' === $method) {
curl_setopt($curlSession, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($curlSession, CURLOPT_POST, true);
curl_setopt($curlSession, CURLOPT_POSTFIELDS, $content);
}
if (! empty($headers)) {
curl_setopt($curlSession, CURLOPT_HTTPHEADER, $headers);
}
if (HTTP_VERIFY_SSL_CERTIFICATE === false) {
curl_setopt($curlSession, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($curlSession, CURLOPT_SSL_VERIFYPEER, false);
}
if (HTTP_PROXY_HOSTNAME) {
curl_setopt($curlSession, CURLOPT_PROXY, HTTP_PROXY_HOSTNAME);
curl_setopt($curlSession, CURLOPT_PROXYPORT, HTTP_PROXY_PORT);
curl_setopt($curlSession, CURLOPT_NOPROXY, HTTP_PROXY_EXCLUDE);
}
if (HTTP_PROXY_USERNAME) {
curl_setopt($curlSession, CURLOPT_PROXYAUTH, CURLAUTH_BASIC);
curl_setopt($curlSession, CURLOPT_PROXYUSERPWD, HTTP_PROXY_USERNAME.':'.HTTP_PROXY_PASSWORD);
}
$body = curl_exec($curlSession);
if ($body === false) {
$errorMsg = curl_error($curlSession);
curl_close($curlSession);
$this->logger->error('HttpClient: request failed ('.$url.' - '.$errorMsg.')');
if ($raiseForErrors) {
throw new ClientException('Unreachable URL: '.$url.' ('.$errorMsg.')');
}
return '';
}
if ($raiseForErrors) {
$statusCode = curl_getinfo($curlSession, CURLINFO_RESPONSE_CODE);
if ($statusCode >= 400) {
curl_close($curlSession);
throw new InvalidStatusException('Request failed with status code '.$statusCode, $statusCode, $body);
}
}
if (DEBUG) {
$this->logger->debug('HttpClient: url='.$url);
$this->logger->debug('HttpClient: headers='.var_export($headers, true));
$this->logger->debug('HttpClient: payload='.$content);
$this->logger->debug('HttpClient: metadata='.var_export(curl_getinfo($curlSession), true));
$this->logger->debug('HttpClient: body='.$body);
$this->logger->debug('HttpClient: executionTime='.(microtime(true) - $startTime));
}
curl_close($curlSession);
return $body;
}
/**
* Get stream context
*
* @access private
* @param string $method
* @param string $content
* @param string[] $headers
* @param bool $raiseForErrors
* @param bool $followRedirects
* @return array
*/
private function getContext($method, $content, array $headers, $raiseForErrors = false, $followRedirects = true)
{
$default_headers = [
'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 = [
'http' => [
'method' => $method,
'protocol_version' => 1.1,
'timeout' => HTTP_TIMEOUT,
'max_redirects' => $followRedirects ? HTTP_MAX_REDIRECTS : 0,
'follow_location' => $followRedirects ? 1 : 0,
'header' => implode("\r\n", $headers),
'content' => $content,
'ignore_errors' => $raiseForErrors,
]
];
if (HTTP_PROXY_HOSTNAME) {
$context['http']['proxy'] = 'tcp://'.HTTP_PROXY_HOSTNAME.':'.HTTP_PROXY_PORT;
$context['http']['request_fulluri'] = true;
}
if (HTTP_VERIFY_SSL_CERTIFICATE === false) {
$context['ssl'] = [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
];
}
return $context;
}
private function getStatusCode(array $lines)
{
$status = 200;
foreach ($lines as $line) {
if (strpos($line, 'HTTP/1') === 0) {
$status = (int) substr($line, 9, 3);
}
}
return $status;
}
/**
* Get backend used for making HTTP connections
*
* @access public
* @return string
*/
public static function backend()
{
return function_exists('curl_version') ? 'cURL' : 'socket';
}
/**
* Check if an IP address is private
*
* @access public
* @param string $ip
* @return bool
*/
public function isPrivateIpAddress($ip)
{
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
return false;
}
// IPv4-mapped IPv6 address (::ffff:a.b.c.d), check the IPv4 part
$address = inet_pton($ip);
if (strlen($address) === 16 && substr($address, 0, 12) === str_repeat("\0", 10)."\xff\xff") {
$ip = inet_ntop(substr($address, 12));
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
}
/**
* Check if a URL is private (RFC1918, localhost, etc.)
*
* @access public
* @param string $url
* @return bool
*/
public function isPrivateURL($url)
{
$parsedUrl = parse_url($url);
if (!isset($parsedUrl['scheme']) || !in_array(strtolower($parsedUrl['scheme']), ['http', 'https'], true)) {
return false;
}
if (!isset($parsedUrl['host'])) {
return false;
}
$host = rtrim(trim($parsedUrl['host'], " \t\n\r\0\x0B[]"), '.');
if ($host === '') {
return false;
}
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return $this->isPrivateIpAddress($host);
}
$addresses = gethostbynamel($host) ?: [];
if (function_exists('dns_get_record')) {
$dnsRecords = @dns_get_record($host, DNS_AAAA);
if (is_array($dnsRecords)) {
foreach ($dnsRecords as $record) {
if (isset($record['type']) && $record['type'] === 'AAAA' && isset($record['ipv6'])) {
$addresses[] = $record['ipv6'];
}
}
}
}
// Refuse hosts we cannot resolve: the HTTP client may still connect to them,
// for example cURL reads "0x7f000001" as 127.0.0.1.
if (empty($addresses)) {
return true;
}
foreach ($addresses as $address) {
if ($this->isPrivateIpAddress($address)) {
return true;
}
}
return false;
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Kanboard\Formatter;
use DateTime;
use Eluceo\iCal\Component\Calendar;
use Eluceo\iCal\Component\Event;
use Eluceo\iCal\Property\Event\Attendees;
use Eluceo\iCal\Property\Event\Organizer;
use Kanboard\Core\Filter\FormatterInterface;
use PicoDb\Table;
/**
* iCal event formatter for tasks
*
* @package formatter
* @author Frederic Guillot
*/
class TaskICalFormatter extends BaseFormatter implements FormatterInterface
{
/**
* Calendar object
*
* @access protected
* @var \Eluceo\iCal\Component\Calendar
*/
protected $vCalendar;
/**
* Get Ical events
*
* @access public
* @return string
*/
public function format()
{
return $this->vCalendar->render();
}
/**
* Set calendar object
*
* @access public
* @param \Eluceo\iCal\Component\Calendar $vCalendar
* @return $this
*/
public function setCalendar(Calendar $vCalendar)
{
$this->vCalendar = $vCalendar;
return $this;
}
/**
* Transform results to iCal events
*
* @access public
* @param Table $query
* @param string $startColumn
* @param string $endColumn
* @return $this
*/
public function addTasksWithStartAndDueDate(Table $query, $startColumn, $endColumn)
{
foreach ($query->findAll() as $task) {
$start = new DateTime;
$start->setTimestamp($task[$startColumn]);
$end = new DateTime;
$end->setTimestamp($task[$endColumn] ?: time());
$vEvent = $this->getTaskIcalEvent($task, 'task-#'.$task['id'].'-'.$startColumn.'-'.$endColumn);
$vEvent->setDtStart($start);
$vEvent->setDtEnd($end);
$this->vCalendar->addComponent($vEvent);
}
return $this;
}
/**
* Transform results to all day iCal events
*
* @access public
* @param Table $query
* @return $this
*/
public function addTasksWithDueDateOnly(Table $query)
{
foreach ($query->findAll() as $task) {
$date = new DateTime;
$date->setTimestamp($task['date_due']);
$vEvent = $this->getTaskIcalEvent($task, 'task-#'.$task['id'].'-date_due');
$vEvent->setDtStart($date);
$vEvent->setDtEnd($date);
if ($date->format('Hi') === '0000') {
$vEvent->setNoTime(true);
}
$this->vCalendar->addComponent($vEvent);
}
return $this;
}
/**
* Get common events for task iCal events
*
* @access protected
* @param array $task
* @param string $uid
* @return Event
*/
protected function getTaskIcalEvent(array &$task, $uid)
{
$dateCreation = new DateTime;
$dateCreation->setTimestamp($task['date_creation']);
$dateModif = new DateTime;
$dateModif->setTimestamp($task['date_modification']);
$vEvent = new Event($uid);
$vEvent->setCreated($dateCreation);
$vEvent->setModified($dateModif);
$vEvent->setUseTimezone(true);
$vEvent->setSummary(t('#%d', $task['id']).' '.$task['title']);
$vEvent->setDescription($task['description']);
$vEvent->setDescriptionHTML($this->helper->text->markdown($task['description']));
$vEvent->setUrl($this->helper->url->base().$this->helper->url->to('TaskViewController', 'show', array('task_id' => $task['id'])));
if (! empty($task['owner_id'])) {
$attendees = new Attendees;
$attendees->add(
'MAILTO:'.$task['assignee_username'].'@kanboard.local',
array('CN' => $task['assignee_name'] ?: $task['assignee_username'])
);
$vEvent->setAttendees($attendees);
}
if (! empty($task['creator_id'])) {
$vEvent->setOrganizer(new Organizer(
'MAILTO:'.$task['creator_username'].'@kanboard.local',
array('CN' => $task['creator_name'] ?: $task['creator_username'])
));
}
return $vEvent;
}
}
+413
View File
@@ -0,0 +1,413 @@
<?php
namespace Kanboard\Model;
use Kanboard\Core\Base;
/**
* Task Finder model
*
* @package Kanboard\Model
* @author Frederic Guillot
*/
class TaskFinderModel extends Base
{
/**
* Get query for project user overview
*
* @access public
* @param array $project_ids
* @param integer $is_active
* @return \PicoDb\Table
*/
public function getProjectUserOverviewQuery(array $project_ids, $is_active)
{
if (empty($project_ids)) {
$project_ids = array(-1);
}
return $this->db
->table(TaskModel::TABLE)
->columns(
TaskModel::TABLE.'.id',
TaskModel::TABLE.'.title',
TaskModel::TABLE.'.date_due',
TaskModel::TABLE.'.date_started',
TaskModel::TABLE.'.project_id',
TaskModel::TABLE.'.color_id',
TaskModel::TABLE.'.priority',
TaskModel::TABLE.'.time_spent',
TaskModel::TABLE.'.time_estimated',
ProjectModel::TABLE.'.name AS project_name',
ColumnModel::TABLE.'.title AS column_name',
UserModel::TABLE.'.username AS assignee_username',
UserModel::TABLE.'.name AS assignee_name'
)
->eq(TaskModel::TABLE.'.is_active', $is_active)
->in(ProjectModel::TABLE.'.id', $project_ids)
->join(ProjectModel::TABLE, 'id', 'project_id')
->join(ColumnModel::TABLE, 'id', 'column_id', TaskModel::TABLE)
->join(UserModel::TABLE, 'id', 'owner_id', TaskModel::TABLE);
}
/**
* Get query for assigned user tasks
*
* @access public
* @param integer $user_id User id
* @return \PicoDb\Table
*/
public function getUserQuery($user_id)
{
return $this->getExtendedQuery()
->beginOr()
->eq(TaskModel::TABLE.'.owner_id', $user_id)
->inSubquery(TaskModel::TABLE.'.id', $this->db->table(SubtaskModel::TABLE)->columns('task_id')->eq('user_id', $user_id))
->closeOr()
->eq(TaskModel::TABLE.'.is_active', TaskModel::STATUS_OPEN)
->eq(ProjectModel::TABLE.'.is_active', ProjectModel::ACTIVE)
->eq(ColumnModel::TABLE.'.hide_in_dashboard', 0);
}
/**
* Extended query
*
* @access public
* @return \PicoDb\Table
*/
public function getExtendedQuery()
{
return $this->db
->table(TaskModel::TABLE)
->columns(
'(SELECT COUNT(*) FROM '.CommentModel::TABLE.' WHERE task_id=tasks.id) AS nb_comments',
'(SELECT COUNT(*) FROM '.TaskFileModel::TABLE.' WHERE task_id=tasks.id) AS nb_files',
'(SELECT COUNT(*) FROM '.SubtaskModel::TABLE.' WHERE '.SubtaskModel::TABLE.'.task_id=tasks.id) AS nb_subtasks',
'(SELECT COUNT(*) FROM '.SubtaskModel::TABLE.' WHERE '.SubtaskModel::TABLE.'.task_id=tasks.id AND status=2) AS nb_completed_subtasks',
'(SELECT COUNT(*) FROM '.TaskLinkModel::TABLE.' WHERE '.TaskLinkModel::TABLE.'.task_id = tasks.id) AS nb_links',
'(SELECT COUNT(*) FROM '.TaskExternalLinkModel::TABLE.' WHERE '.TaskExternalLinkModel::TABLE.'.task_id = tasks.id) AS nb_external_links',
'(SELECT DISTINCT 1 FROM '.TaskLinkModel::TABLE.' tl JOIN '.LinkModel::TABLE.' l ON tl.link_id = l.id WHERE tl.task_id = tasks.id AND l.label = '."'is a milestone of') AS is_milestone",
TaskModel::TABLE.'.id',
TaskModel::TABLE.'.reference',
TaskModel::TABLE.'.title',
TaskModel::TABLE.'.description',
TaskModel::TABLE.'.date_creation',
TaskModel::TABLE.'.date_modification',
TaskModel::TABLE.'.date_completed',
TaskModel::TABLE.'.date_started',
TaskModel::TABLE.'.date_due',
TaskModel::TABLE.'.color_id',
TaskModel::TABLE.'.project_id',
TaskModel::TABLE.'.column_id',
TaskModel::TABLE.'.swimlane_id',
TaskModel::TABLE.'.owner_id',
TaskModel::TABLE.'.creator_id',
TaskModel::TABLE.'.position',
TaskModel::TABLE.'.is_active',
TaskModel::TABLE.'.score',
TaskModel::TABLE.'.category_id',
TaskModel::TABLE.'.priority',
TaskModel::TABLE.'.date_moved',
TaskModel::TABLE.'.recurrence_status',
TaskModel::TABLE.'.recurrence_trigger',
TaskModel::TABLE.'.recurrence_factor',
TaskModel::TABLE.'.recurrence_timeframe',
TaskModel::TABLE.'.recurrence_basedate',
TaskModel::TABLE.'.recurrence_parent',
TaskModel::TABLE.'.recurrence_child',
TaskModel::TABLE.'.time_estimated',
TaskModel::TABLE.'.time_spent',
TaskModel::TABLE.'.reference',
UserModel::TABLE.'.username AS assignee_username',
UserModel::TABLE.'.name AS assignee_name',
UserModel::TABLE.'.email AS assignee_email',
UserModel::TABLE.'.avatar_path AS assignee_avatar_path',
CategoryModel::TABLE.'.name AS category_name',
CategoryModel::TABLE.'.description AS category_description',
CategoryModel::TABLE.'.color_id AS category_color_id',
ColumnModel::TABLE.'.title AS column_name',
ColumnModel::TABLE.'.position AS column_position',
SwimlaneModel::TABLE.'.name AS swimlane_name',
ProjectModel::TABLE.'.name AS project_name'
)
->join(UserModel::TABLE, 'id', 'owner_id', TaskModel::TABLE)
->left(UserModel::TABLE, 'uc', 'id', TaskModel::TABLE, 'creator_id')
->join(CategoryModel::TABLE, 'id', 'category_id', TaskModel::TABLE)
->join(ColumnModel::TABLE, 'id', 'column_id', TaskModel::TABLE)
->join(SwimlaneModel::TABLE, 'id', 'swimlane_id', TaskModel::TABLE)
->join(ProjectModel::TABLE, 'id', 'project_id', TaskModel::TABLE);
}
/**
* Get all tasks for a given project and status
*
* @access public
* @param integer $project_id Project id
* @param integer $status_id Status id
* @return array
*/
public function getAll($project_id, $status_id = TaskModel::STATUS_OPEN)
{
return $this->db
->table(TaskModel::TABLE)
->eq(TaskModel::TABLE.'.project_id', $project_id)
->eq(TaskModel::TABLE.'.is_active', $status_id)
->asc(TaskModel::TABLE.'.id')
->findAll();
}
/**
* Get all tasks for a given project and status
*
* @access public
* @param integer $project_id
* @param array $status
* @return array
*/
public function getAllIds($project_id, array $status = array(TaskModel::STATUS_OPEN))
{
return $this->db
->table(TaskModel::TABLE)
->eq(TaskModel::TABLE.'.project_id', $project_id)
->in(TaskModel::TABLE.'.is_active', $status)
->asc(TaskModel::TABLE.'.id')
->findAllByColumn(TaskModel::TABLE.'.id');
}
/**
* Get overdue tasks query
*
* @access public
* @return \PicoDb\Table
*/
public function getOverdueTasksQuery()
{
return $this->db->table(TaskModel::TABLE)
->columns(
TaskModel::TABLE.'.id',
TaskModel::TABLE.'.title',
TaskModel::TABLE.'.date_due',
TaskModel::TABLE.'.project_id',
TaskModel::TABLE.'.creator_id',
TaskModel::TABLE.'.owner_id',
ProjectModel::TABLE.'.name AS project_name',
UserModel::TABLE.'.username AS assignee_username',
UserModel::TABLE.'.name AS assignee_name'
)
->join(ProjectModel::TABLE, 'id', 'project_id')
->join(UserModel::TABLE, 'id', 'owner_id')
->eq(ProjectModel::TABLE.'.is_active', 1)
->eq(TaskModel::TABLE.'.is_active', 1)
->neq(TaskModel::TABLE.'.date_due', 0)
->lte(TaskModel::TABLE.'.date_due', time());
}
/**
* Get a list of overdue tasks for all projects
*
* @access public
* @return array
*/
public function getOverdueTasks()
{
return $this->getOverdueTasksQuery()->findAll();
}
/**
* Get a list of overdue tasks by project
*
* @access public
* @param integer $project_id
* @return array
*/
public function getOverdueTasksByProject($project_id)
{
return $this->getOverdueTasksQuery()->eq(TaskModel::TABLE.'.project_id', $project_id)->findAll();
}
/**
* Get a list of overdue tasks by user
*
* @access public
* @param integer $user_id
* @return array
*/
public function getOverdueTasksByUser($user_id)
{
return $this->getOverdueTasksQuery()->eq(TaskModel::TABLE.'.owner_id', $user_id)->findAll();
}
/**
* Get project id for a given task
*
* @access public
* @param integer $task_id Task id
* @return integer
*/
public function getProjectId($task_id)
{
return (int) $this->db->table(TaskModel::TABLE)->eq('id', $task_id)->findOneColumn('project_id') ?: 0;
}
/**
* Fetch a task by the id
*
* @access public
* @param integer $task_id Task id
* @return array
*/
public function getById($task_id)
{
return $this->db->table(TaskModel::TABLE)->eq('id', $task_id)->findOne();
}
/**
* Fetch a task by the reference (external id)
*
* @access public
* @param integer $project_id Project id
* @param string $reference Task reference
* @return array
*/
public function getByReference($project_id, $reference)
{
return $this->db->table(TaskModel::TABLE)->eq('project_id', $project_id)->eq('reference', $reference)->findOne();
}
/**
* Get task details (fetch more information from other tables)
*
* @access public
* @param integer $task_id Task id
* @return array
*/
public function getDetails($task_id)
{
return $this->db->table(TaskModel::TABLE)
->columns(
TaskModel::TABLE.'.*',
CategoryModel::TABLE.'.name AS category_name',
SwimlaneModel::TABLE.'.name AS swimlane_name',
ProjectModel::TABLE.'.name AS project_name',
ColumnModel::TABLE.'.title AS column_title',
UserModel::TABLE.'.username AS assignee_username',
UserModel::TABLE.'.name AS assignee_name',
'uc.username AS creator_username',
'uc.name AS creator_name',
CategoryModel::TABLE.'.description AS category_description',
ColumnModel::TABLE.'.position AS column_position'
)
->join(UserModel::TABLE, 'id', 'owner_id', TaskModel::TABLE)
->left(UserModel::TABLE, 'uc', 'id', TaskModel::TABLE, 'creator_id')
->join(CategoryModel::TABLE, 'id', 'category_id', TaskModel::TABLE)
->join(ColumnModel::TABLE, 'id', 'column_id', TaskModel::TABLE)
->join(SwimlaneModel::TABLE, 'id', 'swimlane_id', TaskModel::TABLE)
->join(ProjectModel::TABLE, 'id', 'project_id', TaskModel::TABLE)
->eq(TaskModel::TABLE.'.id', $task_id)
->findOne();
}
/**
* Get iCal query
*
* @access public
* @return \PicoDb\Table
*/
public function getICalQuery()
{
return $this->db->table(TaskModel::TABLE)
->left(UserModel::TABLE, 'ua', 'id', TaskModel::TABLE, 'owner_id')
->left(UserModel::TABLE, 'uc', 'id', TaskModel::TABLE, 'creator_id')
->columns(
TaskModel::TABLE.'.*',
'ua.name AS assignee_name',
'ua.username AS assignee_username',
'uc.name AS creator_name',
'uc.username AS creator_username'
);
}
/**
* Count all tasks for a given project and status
*
* @access public
* @param integer $project_id Project id
* @param array $status List of status id
* @return integer
*/
public function countByProjectId($project_id, array $status = array(TaskModel::STATUS_OPEN, TaskModel::STATUS_CLOSED))
{
return $this->db
->table(TaskModel::TABLE)
->eq('project_id', $project_id)
->in('is_active', $status)
->count();
}
/**
* Count the number of tasks for a given column and status
*
* @access public
* @param integer $project_id Project id
* @param integer $column_id Column id
* @param array $status
* @return int
*/
public function countByColumnId($project_id, $column_id, array $status = array(TaskModel::STATUS_OPEN))
{
return $this->db
->table(TaskModel::TABLE)
->eq('project_id', $project_id)
->eq('column_id', $column_id)
->in('is_active', $status)
->count();
}
/**
* Count the number of tasks for a given column and swimlane
*
* @access public
* @param integer $project_id Project id
* @param integer $column_id Column id
* @param integer $swimlane_id Swimlane id
* @return integer
*/
public function countByColumnAndSwimlaneId($project_id, $column_id, $swimlane_id)
{
return $this->db
->table(TaskModel::TABLE)
->eq('project_id', $project_id)
->eq('column_id', $column_id)
->eq('swimlane_id', $swimlane_id)
->eq('is_active', 1)
->count();
}
/**
* Return true if the task exists
*
* @access public
* @param integer $task_id Task id
* @return boolean
*/
public function exists($task_id)
{
return $this->db->table(TaskModel::TABLE)->eq('id', $task_id)->exists();
}
/**
* Get project token
*
* @access public
* @param integer $task_id
* @return string
*/
public function getProjectToken($task_id)
{
return $this->db
->table(TaskModel::TABLE)
->eq(TaskModel::TABLE.'.id', $task_id)
->join(ProjectModel::TABLE, 'id', 'project_id')
->findOneColumn(ProjectModel::TABLE.'.token');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php if ($is_ajax): ?>
<div class="page-header">
<h2><?= $this->text->e($title) ?></h2>
</div>
<?php else: ?>
<?= $this->projectHeader->render($project, 'TaskListController', 'show') ?>
<?php endif ?>
<section class="sidebar-container">
<?= $this->render($sidebar_template, array('project' => $project)) ?>
<div class="sidebar-content">
<?= $content_for_sublayout ?>
</div>
</section>
@@ -0,0 +1,33 @@
<?php
namespace KanboardTests\units\Api\Procedure;
use Kanboard\Api\Procedure\CommentProcedure;
use Kanboard\Core\Security\Role;
use KanboardTests\units\Base;
class CommentProcedureTest extends Base
{
public function testCommentsDoNotExposeTheAuthorEmail()
{
$this->assertEquals(2, $this->container['userModel']->create(array('username' => 'owner', 'email' => 'owner@example.org')));
$this->assertEquals(3, $this->container['userModel']->create(array('username' => 'peer')));
$this->assertEquals(1, $this->container['projectModel']->create(array('name' => 'P1')));
$this->assertTrue($this->container['projectUserRoleModel']->addUser(1, 2, Role::PROJECT_MANAGER));
$this->assertTrue($this->container['projectUserRoleModel']->addUser(1, 3, Role::PROJECT_MEMBER));
$this->assertEquals(1, $this->container['taskCreationModel']->create(array('title' => 'Task', 'project_id' => 1)));
$this->assertEquals(1, $this->container['commentModel']->create(array('task_id' => 1, 'user_id' => 2, 'comment' => 'hello')));
$this->container['userSession']->initialize($this->container['userModel']->getById(3));
$procedure = new CommentProcedure($this->container);
$comment = $procedure->getComment(1);
$this->assertEquals('owner', $comment['username']);
$this->assertArrayNotHasKey('email', $comment);
$comments = $procedure->getAllComments(1);
$this->assertCount(1, $comments);
$this->assertEquals('owner', $comments[0]['username']);
$this->assertArrayNotHasKey('email', $comments[0]);
}
}
@@ -0,0 +1,54 @@
<?php
namespace KanboardTests\units\Api\Procedure;
use Kanboard\Api\Procedure\MeProcedure;
use Kanboard\Api\Procedure\ProjectProcedure;
use Kanboard\Core\Security\Role;
use Kanboard\EventBuilder\CommentEventBuilder;
use Kanboard\Model\CommentModel;
use KanboardTests\units\Base;
class ProjectActivityProcedureTest extends Base
{
public function testProjectActivityDoesNotExposeTheAuthorEmail()
{
$this->createCommentEvent();
$procedure = new ProjectProcedure($this->container);
$this->assertEventsWithoutEmail($procedure->getProjectActivity(1));
$this->assertEventsWithoutEmail($procedure->getProjectActivities(array(1)));
}
public function testMyActivityStreamDoesNotExposeTheAuthorEmail()
{
$this->createCommentEvent();
$procedure = new MeProcedure($this->container);
$this->assertEventsWithoutEmail($procedure->getMyActivityStream());
}
private function assertEventsWithoutEmail(array $events)
{
$this->assertCount(1, $events);
$this->assertEquals('owner', $events[0]['author_username']);
$this->assertEquals('hello', $events[0]['comment']['comment']);
$this->assertStringNotContainsString('owner@example.org', json_encode($events));
}
private function createCommentEvent()
{
$this->assertEquals(2, $this->container['userModel']->create(array('username' => 'owner', 'email' => 'owner@example.org')));
$this->assertEquals(3, $this->container['userModel']->create(array('username' => 'peer')));
$this->assertEquals(1, $this->container['projectModel']->create(array('name' => 'P1')));
$this->assertTrue($this->container['projectUserRoleModel']->addUser(1, 2, Role::PROJECT_MANAGER));
$this->assertTrue($this->container['projectUserRoleModel']->addUser(1, 3, Role::PROJECT_MEMBER));
$this->assertEquals(1, $this->container['taskCreationModel']->create(array('title' => 'Task', 'project_id' => 1)));
$this->assertEquals(1, $this->container['commentModel']->create(array('task_id' => 1, 'user_id' => 2, 'comment' => 'hello')));
$event = (new CommentEventBuilder($this->container))->withCommentId(1)->buildEvent();
$this->assertNotFalse($this->container['projectActivityModel']->createEvent(1, 1, 2, CommentModel::EVENT_CREATE, $event->getAll()));
$this->container['userSession']->initialize($this->container['userModel']->getById(3));
}
}
@@ -0,0 +1,41 @@
<?php
namespace KanboardTests\units\Controller;
use Kanboard\Controller\CurrencyController;
use Kanboard\Core\Http\Request;
use Kanboard\Core\Http\Response;
use KanboardTests\units\Base;
class CurrencyControllerTest extends Base
{
public function testChangingTheCurrencyDoesNotSaveOtherSettings()
{
$this->container['request'] = new Request(
$this->container,
array('REQUEST_METHOD' => 'POST'),
array(),
array(
'csrf_token' => $this->container['token']->getCSRFToken(),
'application_currency' => 'EUR',
'calendar_project_tasks' => '1 OR 1=1 OR 1',
),
array(),
array()
);
$this->container['response'] = $this->getMockBuilder(Response::class)
->setConstructorArgs(array($this->container))
->onlyMethods(array('redirect'))
->getMock();
$this->container['response']
->expects($this->once())
->method('redirect');
(new CurrencyController($this->container))->update();
$this->assertEquals('EUR', $this->container['configModel']->getOption('application_currency'));
$this->assertEquals('date_started', $this->container['configModel']->getOption('calendar_project_tasks'));
}
}
@@ -0,0 +1,92 @@
<?php
namespace KanboardTests\units\Controller;
use Kanboard\Controller\ICalendarController;
use Kanboard\Core\Http\Request;
use Kanboard\Core\Http\Response;
use KanboardTests\units\Base;
class ICalendarControllerTest extends Base
{
private $calendar = '';
public function testProjectCalendarIgnoresUnknownStartColumn()
{
$this->container['configModel']->save(array('calendar_project_tasks' => '1 OR 1=1 OR 1'));
$this->createFixtures();
$project = $this->container['projectModel']->getById(1);
$this->renderCalendar(array('token' => $project['token']))->project();
$this->assertStringNotContainsString('Private task', $this->calendar);
$this->assertStringContainsString('Public task', $this->calendar);
$this->assertStringContainsString('task-#1-date_started-date_due', $this->calendar);
}
public function testUserCalendarIgnoresUnknownStartColumn()
{
$this->container['configModel']->save(array('calendar_user_tasks' => '1 OR 1=1 OR 1'));
$this->createFixtures();
$user = $this->container['userModel']->getById(2);
$this->renderCalendar(array('token' => $user['token']))->user();
$this->assertStringNotContainsString('Private task', $this->calendar);
$this->assertStringContainsString('Public task', $this->calendar);
}
public function testProjectCalendarUsesCreationDateWhenConfigured()
{
$this->container['configModel']->save(array('calendar_project_tasks' => 'date_creation'));
$this->createFixtures();
$project = $this->container['projectModel']->getById(1);
$this->renderCalendar(array('token' => $project['token']))->project();
$this->assertStringContainsString('task-#1-date_creation-date_due', $this->calendar);
}
private function createFixtures()
{
$this->assertEquals(2, $this->container['userModel']->create(array('username' => 'user1')));
$this->assertTrue($this->container['userModel']->enablePublicAccess(2));
$this->assertEquals(1, $this->container['projectModel']->create(array('name' => 'Public project')));
$this->assertTrue($this->container['projectModel']->enablePublicAccess(1));
$this->assertEquals(2, $this->container['projectModel']->create(array('name' => 'Private project')));
$this->assertEquals(1, $this->createTask(1, 'Public task', 2));
$this->assertEquals(2, $this->createTask(2, 'Private task', 1));
}
private function createTask($projectId, $title, $ownerId)
{
return $this->container['taskCreationModel']->create(array(
'project_id' => $projectId,
'title' => $title,
'owner_id' => $ownerId,
'date_started' => strtotime('-1 day'),
'date_due' => strtotime('+1 day'),
));
}
private function renderCalendar(array $params)
{
$this->container['request'] = new Request($this->container, array('REQUEST_METHOD' => 'GET'), $params);
$this->container['response'] = $this->getMockBuilder(Response::class)
->setConstructorArgs(array($this->container))
->onlyMethods(array('ical'))
->getMock();
$this->container['response']
->expects($this->once())
->method('ical')
->willReturnCallback(function ($calendar) {
$this->calendar = $calendar;
});
return new ICalendarController($this->container);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace KanboardTests\units\Core\Http;
use KanboardTests\units\Base;
use Kanboard\Core\Http\Client;
class ClientTest extends Base
{
public function testGetCanDisableRedirects()
{
$client = $this
->getMockBuilder('\Kanboard\Core\Http\Client')
->setConstructorArgs(array($this->container))
->onlyMethods(array('doRequest'))
->getMock();
$client
->expects($this->once())
->method('doRequest')
->with('GET', 'https://example.org', '', array('X-Test: 1'), true, false)
->willReturn('response-body');
$this->assertSame(
'response-body',
$client->get('https://example.org', array('X-Test: 1'), true, false)
);
}
public function testIsPrivateIpAddressWithPrivateIPv4()
{
$client = new Client($this->container);
$this->assertTrue($client->isPrivateIpAddress('10.0.0.1'));
$this->assertTrue($client->isPrivateIpAddress('172.16.5.10'));
$this->assertTrue($client->isPrivateIpAddress('192.168.1.20'));
$this->assertTrue($client->isPrivateIpAddress('127.0.0.1'));
}
public function testIsPrivateIpAddressWithPrivateIPv6()
{
$client = new Client($this->container);
$this->assertTrue($client->isPrivateIpAddress('::1'));
$this->assertTrue($client->isPrivateIpAddress('fd12:3456:789a:1::1'));
}
public function testIsPrivateIpAddressWithIPv4MappedIPv6()
{
$client = new Client($this->container);
$this->assertTrue($client->isPrivateIpAddress('::ffff:127.0.0.1'));
$this->assertTrue($client->isPrivateIpAddress('::ffff:7f00:1'));
$this->assertTrue($client->isPrivateIpAddress('::ffff:169.254.169.254'));
$this->assertTrue($client->isPrivateIpAddress('::ffff:10.0.0.1'));
$this->assertFalse($client->isPrivateIpAddress('::ffff:8.8.8.8'));
}
public function testIsPrivateIpAddressWithPublicAndInvalidValues()
{
$client = new Client($this->container);
$this->assertFalse($client->isPrivateIpAddress('8.8.8.8'));
$this->assertFalse($client->isPrivateIpAddress('2607:f8b0:4005:805::200e'));
$this->assertFalse($client->isPrivateIpAddress('not-an-ip'));
$this->assertFalse($client->isPrivateIpAddress(''));
}
public function testIsPrivateUrlWithPrivateHostnames()
{
$client = new Client($this->container);
$this->assertTrue($client->isPrivateURL('http://localhost'));
$this->assertTrue($client->isPrivateURL('http://127.0.0.1/path'));
$this->assertTrue($client->isPrivateURL('http://10.0.0.15/api'));
}
public function testIsPrivateUrlWithBracketedIPv6Literals()
{
$client = new Client($this->container);
$this->assertTrue($client->isPrivateURL('http://[::1]/'));
$this->assertTrue($client->isPrivateURL('http://[::ffff:127.0.0.1]:9099/'));
$this->assertTrue($client->isPrivateURL('http://[0:0:0:0:0:ffff:127.0.0.1]/'));
$this->assertTrue($client->isPrivateURL('http://[::ffff:169.254.169.254]/'));
$this->assertTrue($client->isPrivateURL('http://[fd00::1]/'));
$this->assertFalse($client->isPrivateURL('http://[2607:f8b0:4005:805::200e]/'));
}
public function testIsPrivateUrlWithOtherIPv4Notations()
{
$client = new Client($this->container);
$this->assertTrue($client->isPrivateURL('http://2130706433:9000/'));
$this->assertTrue($client->isPrivateURL('http://0x7f000001/'));
$this->assertTrue($client->isPrivateURL('http://0177.0.0.1/'));
$this->assertTrue($client->isPrivateURL('http://2852039166/'));
$this->assertTrue($client->isPrivateURL('http://127.0.0.1./'));
}
public function testIsPrivateUrlWithUnresolvableHost()
{
$client = new Client($this->container);
$this->assertTrue($client->isPrivateURL('http://kanboard.invalid/'));
}
public function testIsPrivateUrlWithPublicAddresses()
{
$client = new Client($this->container);
$this->assertFalse($client->isPrivateURL('http://8.8.8.8/data'));
$this->assertFalse($client->isPrivateURL('https://ipv6.google.com/test'));
}
public function testIsPrivateUrlWithUnsupportedScheme()
{
$client = new Client($this->container);
$this->assertFalse($client->isPrivateURL('ftp://127.0.0.1'));
$this->assertFalse($client->isPrivateURL('mailto:test@example.com'));
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace KanboardTests\units\Core;
use KanboardTests\units\Base;
use Kanboard\Core\Http\Request;
use Kanboard\Core\Security\Role;
use Kanboard\Core\Template;
class TemplateTest extends Base
{
public function testGetTemplateFile()
{
$template = new Template($this->container['helper']);
$this->assertStringEndsWith(
implode(DIRECTORY_SEPARATOR, array('app', 'Core', '..', 'Template', 'a', 'b.php')),
$template->getTemplateFile('a'.DIRECTORY_SEPARATOR.'b')
);
$this->assertStringEndsWith(
implode(DIRECTORY_SEPARATOR, array('app', 'Core', '..', 'Template', 'a', 'b.php')),
$template->getTemplateFile('kanboard:a'.DIRECTORY_SEPARATOR.'b')
);
}
public function testGetPluginTemplateFile()
{
$template = new Template($this->container['helper']);
$this->assertStringEndsWith(
implode(DIRECTORY_SEPARATOR, array(PLUGINS_DIR, 'Myplugin', 'Template', 'a', 'b.php')),
$template->getTemplateFile('myplugin:a'.DIRECTORY_SEPARATOR.'b')
);
}
public function testGetOverridedTemplateFile()
{
$template = new Template($this->container['helper']);
$template->setTemplateOverride('a'.DIRECTORY_SEPARATOR.'b', 'myplugin:c');
$this->assertStringEndsWith(
implode(DIRECTORY_SEPARATOR, array(PLUGINS_DIR, 'Myplugin', 'Template', 'c.php')),
$template->getTemplateFile('a'.DIRECTORY_SEPARATOR.'b')
);
$this->assertStringEndsWith(
implode(DIRECTORY_SEPARATOR, array('app', 'Core', '..', 'Template', 'd.php')),
$template->getTemplateFile('d')
);
}
public function testCommentReplyEscapesTemplateClosingTags()
{
$_SESSION['user'] = array(
'id' => 2,
'role' => Role::APP_USER,
);
$html = $this->container['template']->render('comment/show', array(
'comment' => array(
'id' => 1,
'user_id' => 1,
'username' => 'alice',
'name' => 'Alice',
'email' => '',
'avatar_path' => '',
'date_creation' => 0,
'date_modification' => 0,
'visibility' => Role::APP_USER,
'comment' => '</textarea></template><base href="http://127.0.0.1:8899/">',
),
'task' => array('id' => 1),
'editable' => false,
'hide_actions' => true,
));
$this->assertStringNotContainsString(
'<base href="http://127.0.0.1:8899/">',
$html
);
$this->assertStringContainsString(
'&lt;/textarea&gt;&lt;/template&gt;&lt;base href=&quot;http://127.0.0.1:8899/&quot;&gt;',
$html
);
}
public function testAnalyticAjaxLayoutEscapesProjectNameInTitle()
{
$this->container['request'] = new Request($this->container, array(
'HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest',
));
$html = $this->container['helper']->layout->analytic('analytic/task_distribution', array(
'project' => array('id' => 1, 'name' => '<img src=x onerror=alert(document.domain)>'),
'metrics' => array(),
'title' => 'Task distribution',
));
$this->assertStringNotContainsString(
'<img src=x onerror=alert(document.domain)>',
$html
);
$this->assertStringContainsString(
'<h2>&lt;img src=x onerror=alert(document.domain)&gt; &gt; Task distribution</h2>',
$html
);
}
}
@@ -0,0 +1,33 @@
<?php
namespace KanboardTests\units\Formatter;
use Eluceo\iCal\Component\Calendar;
use Kanboard\Formatter\TaskICalFormatter;
use KanboardTests\units\Base;
class TaskICalFormatterTest extends Base
{
public function testAttendeeAndOrganizerDoNotExposeEmailAddresses()
{
$this->assertEquals(2, $this->container['userModel']->create(array('username' => 'creator', 'name' => 'Task Creator', 'email' => 'creator@example.org')));
$this->assertEquals(3, $this->container['userModel']->create(array('username' => 'assignee', 'name' => 'Task Assignee', 'email' => 'assignee@example.org')));
$this->assertEquals(1, $this->container['projectModel']->create(array('name' => 'P1')));
$this->assertEquals(1, $this->container['taskCreationModel']->create(array(
'project_id' => 1,
'title' => 'Task',
'creator_id' => 2,
'owner_id' => 3,
'date_due' => strtotime('+1 day'),
)));
$calendar = TaskICalFormatter::getInstance($this->container)
->setCalendar(new Calendar('Kanboard'))
->addTasksWithDueDateOnly($this->container['taskFinderModel']->getICalQuery())
->format();
$this->assertStringContainsString('ATTENDEE;CN=Task Assignee:MAILTO:assignee@kanboard.local', $calendar);
$this->assertStringContainsString('ORGANIZER;CN=Task Creator:MAILTO:creator@kanboard.local', $calendar);
$this->assertStringNotContainsString('@example.org', $calendar);
}
}