看板初始化提交

This commit is contained in:
zephyr
2026-06-01 21:23:12 -07:00
commit 27411ebedc
1827 changed files with 192340 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
<?php
namespace Kanboard\Core\Action;
use Exception;
use RuntimeException;
use Kanboard\Core\Base;
use Kanboard\Action\Base as ActionBase;
/**
* Action Manager
*
* @package action
* @author Frederic Guillot
*/
class ActionManager extends Base
{
/**
* List of automatic actions
*
* @access private
* @var ActionBase[]
*/
private $actions = array();
/**
* Register a new automatic action
*
* @access public
* @param ActionBase $action
* @return ActionManager
*/
public function register(ActionBase $action)
{
$this->actions[$action->getName()] = $action;
return $this;
}
/**
* Get automatic action instance
*
* @access public
* @param string $name Absolute class name with namespace
* @return ActionBase
*/
public function getAction($name)
{
if (isset($this->actions[$name])) {
return $this->actions[$name];
}
throw new RuntimeException('Automatic Action Not Found: '.$name);
}
/**
* Get available automatic actions
*
* @access public
* @return array
*/
public function getAvailableActions()
{
$actions = array();
foreach ($this->actions as $action) {
if (count($action->getEvents()) > 0) {
$actions[$action->getName()] = $action->getDescription();
}
}
asort($actions);
return $actions;
}
/**
* Get all available action parameters
*
* @access public
* @param array $actions
* @return array
*/
public function getAvailableParameters(array $actions)
{
$params = array();
foreach ($actions as $action) {
try {
$currentAction = $this->getAction($action['action_name']);
$params[$currentAction->getName()] = $currentAction->getActionRequiredParameters();
} catch (Exception $e) {
$this->logger->error(__METHOD__.': '.$e->getMessage());
}
}
return $params;
}
/**
* Get list of compatible events for a given action
*
* @access public
* @param string $name
* @return array
*/
public function getCompatibleEvents($name)
{
$events = array();
$actionEvents = $this->getAction($name)->getEvents();
foreach ($this->eventManager->getAll() as $event => $description) {
if (in_array($event, $actionEvents)) {
$events[$event] = $description;
}
}
return $events;
}
/**
* Bind automatic actions to events
*
* @access public
* @return ActionManager
*/
public function attachEvents()
{
if ($this->userSession->isLogged()) {
$actions = $this->actionModel->getAllByUser($this->userSession->getId());
} else {
$actions = $this->actionModel->getAll();
}
foreach ($actions as $action) {
try {
$listener = clone $this->getAction($action['action_name']);
$listener->setProjectId($action['project_id']);
foreach ($action['params'] as $param_name => $param_value) {
$listener->setParam($param_name, $param_value);
}
$this->dispatcher->addListener($action['event_name'], array($listener, 'execute'));
} catch (Exception $e) {
$this->logger->error(__METHOD__.': '.$e->getMessage());
}
}
return $this;
}
/**
* Remove all listeners for automated actions
*
* @access public
*/
public function removeEvents()
{
foreach ($this->dispatcher->getListeners() as $eventName => $listeners) {
foreach ($listeners as $listener) {
if (is_array($listener) && $listener[0] instanceof ActionBase) {
$this->dispatcher->removeListener($eventName, $listener);
}
}
}
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
namespace Kanboard\Core;
use Pimple\Container;
/**
* Base Class
*
* @package Kanboard\Core
* @author Frederic Guillot
*
* @property \Kanboard\Analytic\TaskDistributionAnalytic $taskDistributionAnalytic
* @property \Kanboard\Analytic\UserDistributionAnalytic $userDistributionAnalytic
* @property \Kanboard\Analytic\EstimatedTimeComparisonAnalytic $estimatedTimeComparisonAnalytic
* @property \Kanboard\Analytic\AverageLeadCycleTimeAnalytic $averageLeadCycleTimeAnalytic
* @property \Kanboard\Analytic\AverageTimeSpentColumnAnalytic $averageTimeSpentColumnAnalytic
* @property \Kanboard\Analytic\EstimatedActualColumnAnalytic $estimatedActualColumnAnalytic
* @property \Kanboard\Core\Action\ActionManager $actionManager
* @property \Kanboard\Core\ExternalLink\ExternalLinkManager $externalLinkManager
* @property \Kanboard\Core\ExternalTask\ExternalTaskManager $externalTaskManager
* @property \Kanboard\Core\Cache\MemoryCache $memoryCache
* @property \Kanboard\Core\Cache\BaseCache $cacheDriver
* @property \Kanboard\Core\Event\EventManager $eventManager
* @property \Kanboard\Core\Group\GroupManager $groupManager
* @property \Kanboard\Core\User\UserManager $userManager
* @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\Response $response
* @property \Kanboard\Core\Http\Router $router
* @property \Kanboard\Core\Http\Route $route
* @property \Kanboard\Core\Queue\QueueManager $queueManager
* @property \Kanboard\Core\Mail\Client $emailClient
* @property \Kanboard\Core\ObjectStorage\ObjectStorageInterface $objectStorage
* @property \Kanboard\Core\Plugin\Hook $hook
* @property \Kanboard\Core\Plugin\Loader $pluginLoader
* @property \Kanboard\Core\Security\AuthenticationManager $authenticationManager
* @property \Kanboard\Core\Security\AccessMap $applicationAccessMap
* @property \Kanboard\Core\Security\AccessMap $projectAccessMap
* @property \Kanboard\Core\Security\AccessMap $apiAccessMap
* @property \Kanboard\Core\Security\AccessMap $apiProjectAccessMap
* @property \Kanboard\Core\Security\Authorization $applicationAuthorization
* @property \Kanboard\Core\Security\Authorization $projectAuthorization
* @property \Kanboard\Core\Security\Authorization $apiAuthorization
* @property \Kanboard\Core\Security\Authorization $apiProjectAuthorization
* @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\User\Avatar\AvatarManager $avatarManager
* @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\Paginator $paginator
* @property \Kanboard\Core\Template $template
* @property \Kanboard\Decorator\MetadataCacheDecorator $userMetadataCacheDecorator
* @property \Kanboard\Decorator\UserCacheDecorator $userCacheDecorator
* @property \Kanboard\Decorator\ColumnRestrictionCacheDecorator $columnRestrictionCacheDecorator
* @property \Kanboard\Decorator\ColumnMoveRestrictionCacheDecorator $columnMoveRestrictionCacheDecorator
* @property \Kanboard\Decorator\ProjectRoleRestrictionCacheDecorator $projectRoleRestrictionCacheDecorator
* @property \Kanboard\Formatter\BoardColumnFormatter $boardColumnFormatter
* @property \Kanboard\Formatter\BoardFormatter $boardFormatter
* @property \Kanboard\Formatter\BoardSwimlaneFormatter $boardSwimlaneFormatter
* @property \Kanboard\Formatter\BoardTaskFormatter $boardTaskFormatter
* @property \Kanboard\Formatter\GroupAutoCompleteFormatter $groupAutoCompleteFormatter
* @property \Kanboard\Formatter\ProjectActivityEventFormatter $projectActivityEventFormatter
* @property \Kanboard\Formatter\ProjectApiFormatter $projectApiFormatter
* @property \Kanboard\Formatter\ProjectsApiFormatter $projectsApiFormatter
* @property \Kanboard\Formatter\SubtaskListFormatter $subtaskListFormatter
* @property \Kanboard\Formatter\SubtaskTimeTrackingCalendarFormatter $subtaskTimeTrackingCalendarFormatter
* @property \Kanboard\Formatter\TaskApiFormatter $taskApiFormatter
* @property \Kanboard\Formatter\TasksApiFormatter $tasksApiFormatter
* @property \Kanboard\Formatter\TaskAutoCompleteFormatter $taskAutoCompleteFormatter
* @property \Kanboard\Formatter\TaskICalFormatter $taskICalFormatter
* @property \Kanboard\Formatter\TaskListFormatter $taskListFormatter
* @property \Kanboard\Formatter\TaskListSubtaskFormatter $taskListSubtaskFormatter
* @property \Kanboard\Formatter\TaskListSubtaskAssigneeFormatter $taskListSubtaskAssigneeFormatter
* @property \Kanboard\Formatter\TaskSuggestMenuFormatter $taskSuggestMenuFormatter
* @property \Kanboard\Formatter\UserAutoCompleteFormatter $userAutoCompleteFormatter
* @property \Kanboard\Formatter\UserMentionFormatter $userMentionFormatter
* @property \Kanboard\Model\ActionModel $actionModel
* @property \Kanboard\Model\ActionParameterModel $actionParameterModel
* @property \Kanboard\Model\AvatarFileModel $avatarFileModel
* @property \Kanboard\Model\BoardModel $boardModel
* @property \Kanboard\Model\CaptchaModel $captchaModel
* @property \Kanboard\Model\CategoryModel $categoryModel
* @property \Kanboard\Model\ColorModel $colorModel
* @property \Kanboard\Model\ColumnModel $columnModel
* @property \Kanboard\Model\ColumnRestrictionModel $columnRestrictionModel
* @property \Kanboard\Model\ColumnMoveRestrictionModel $columnMoveRestrictionModel
* @property \Kanboard\Model\CommentModel $commentModel
* @property \Kanboard\Model\ConfigModel $configModel
* @property \Kanboard\Model\CurrencyModel $currencyModel
* @property \Kanboard\Model\CustomFilterModel $customFilterModel
* @property \Kanboard\Model\TaskFileModel $taskFileModel
* @property \Kanboard\Model\ProjectFileModel $projectFileModel
* @property \Kanboard\Model\GroupModel $groupModel
* @property \Kanboard\Model\GroupMemberModel $groupMemberModel
* @property \Kanboard\Model\InviteModel $inviteModel
* @property \Kanboard\Model\LanguageModel $languageModel
* @property \Kanboard\Model\LastLoginModel $lastLoginModel
* @property \Kanboard\Model\LinkModel $linkModel
* @property \Kanboard\Model\NotificationModel $notificationModel
* @property \Kanboard\Model\PasswordResetModel $passwordResetModel
* @property \Kanboard\Model\PredefinedTaskDescriptionModel $predefinedTaskDescriptionModel
* @property \Kanboard\Model\ProjectModel $projectModel
* @property \Kanboard\Model\ProjectActivityModel $projectActivityModel
* @property \Kanboard\Model\ProjectDuplicationModel $projectDuplicationModel
* @property \Kanboard\Model\ProjectDailyColumnStatsModel $projectDailyColumnStatsModel
* @property \Kanboard\Model\ProjectDailyStatsModel $projectDailyStatsModel
* @property \Kanboard\Model\ProjectMetadataModel $projectMetadataModel
* @property \Kanboard\Model\ProjectPermissionModel $projectPermissionModel
* @property \Kanboard\Model\ProjectUserRoleModel $projectUserRoleModel
* @property \Kanboard\Model\ProjectGroupRoleModel $projectGroupRoleModel
* @property \Kanboard\Model\ProjectNotificationModel $projectNotificationModel
* @property \Kanboard\Model\ProjectNotificationTypeModel $projectNotificationTypeModel
* @property \Kanboard\Model\ProjectRoleModel $projectRoleModel
* @property \Kanboard\Model\ProjectRoleRestrictionModel $projectRoleRestrictionModel
* @property \Kanboard\Model\ProjectTaskDuplicationModel $projectTaskDuplicationModel
* @property \Kanboard\Model\ProjectTaskPriorityModel $projectTaskPriorityModel
* @property \Kanboard\Model\RememberMeSessionModel $rememberMeSessionModel
* @property \Kanboard\Model\SubtaskModel $subtaskModel
* @property \Kanboard\Model\SubtaskPositionModel $subtaskPositionModel
* @property \Kanboard\Model\SubtaskStatusModel $subtaskStatusModel
* @property \Kanboard\Model\SubtaskTaskConversionModel $subtaskTaskConversionModel
* @property \Kanboard\Model\SubtaskTimeTrackingModel $subtaskTimeTrackingModel
* @property \Kanboard\Model\SwimlaneModel $swimlaneModel
* @property \Kanboard\Model\TagDuplicationModel $tagDuplicationModel
* @property \Kanboard\Model\TagModel $tagModel
* @property \Kanboard\Model\TaskModel $taskModel
* @property \Kanboard\Model\TaskAnalyticModel $taskAnalyticModel
* @property \Kanboard\Model\TaskCreationModel $taskCreationModel
* @property \Kanboard\Model\TaskDuplicationModel $taskDuplicationModel
* @property \Kanboard\Model\TaskProjectDuplicationModel $taskProjectDuplicationModel
* @property \Kanboard\Model\TaskProjectMoveModel $taskProjectMoveModel
* @property \Kanboard\Model\TaskRecurrenceModel $taskRecurrenceModel
* @property \Kanboard\Model\TaskExternalLinkModel $taskExternalLinkModel
* @property \Kanboard\Model\TaskFinderModel $taskFinderModel
* @property \Kanboard\Model\TaskLinkModel $taskLinkModel
* @property \Kanboard\Model\TaskModificationModel $taskModificationModel
* @property \Kanboard\Model\TaskPositionModel $taskPositionModel
* @property \Kanboard\Model\TaskReorderModel $taskReorderModel
* @property \Kanboard\Model\TaskStatusModel $taskStatusModel
* @property \Kanboard\Model\TaskTagModel $taskTagModel
* @property \Kanboard\Model\TaskMetadataModel $taskMetadataModel
* @property \Kanboard\Model\ThemeModel $themeModel
* @property \Kanboard\Model\TimezoneModel $timezoneModel
* @property \Kanboard\Model\TransitionModel $transitionModel
* @property \Kanboard\Model\UserModel $userModel
* @property \Kanboard\Model\UserLockingModel $userLockingModel
* @property \Kanboard\Model\UserNotificationModel $userNotificationModel
* @property \Kanboard\Model\UserNotificationTypeModel $userNotificationTypeModel
* @property \Kanboard\Model\UserNotificationFilterModel $userNotificationFilterModel
* @property \Kanboard\Model\UserUnreadNotificationModel $userUnreadNotificationModel
* @property \Kanboard\Model\UserMetadataModel $userMetadataModel
* @property \Kanboard\Pagination\DashboardPagination $dashboardPagination
* @property \Kanboard\Pagination\ProjectPagination $projectPagination
* @property \Kanboard\Pagination\TaskPagination $taskPagination
* @property \Kanboard\Pagination\SubtaskPagination $subtaskPagination
* @property \Kanboard\Pagination\UserPagination $userPagination
* @property \Kanboard\Validator\ActionValidator $actionValidator
* @property \Kanboard\Validator\AuthValidator $authValidator
* @property \Kanboard\Validator\ColumnValidator $columnValidator
* @property \Kanboard\Validator\CategoryValidator $categoryValidator
* @property \Kanboard\Validator\ColumnRestrictionValidator $columnRestrictionValidator
* @property \Kanboard\Validator\ColumnMoveRestrictionValidator $columnMoveRestrictionValidator
* @property \Kanboard\Validator\CommentValidator $commentValidator
* @property \Kanboard\Validator\ConfigValidator $configValidator
* @property \Kanboard\Validator\CurrencyValidator $currencyValidator
* @property \Kanboard\Validator\CustomFilterValidator $customFilterValidator
* @property \Kanboard\Validator\ExternalLinkValidator $externalLinkValidator
* @property \Kanboard\Validator\GroupValidator $groupValidator
* @property \Kanboard\Validator\LinkValidator $linkValidator
* @property \Kanboard\Validator\PasswordResetValidator $passwordResetValidator
* @property \Kanboard\Validator\ProjectValidator $projectValidator
* @property \Kanboard\Validator\ProjectRoleValidator $projectRoleValidator
* @property \Kanboard\Validator\SubtaskValidator $subtaskValidator
* @property \Kanboard\Validator\SwimlaneValidator $swimlaneValidator
* @property \Kanboard\Validator\TagValidator $tagValidator
* @property \Kanboard\Validator\TaskLinkValidator $taskLinkValidator
* @property \Kanboard\Validator\TaskValidator $taskValidator
* @property \Kanboard\Validator\UserValidator $userValidator
* @property \Kanboard\Validator\PredefinedTaskDescriptionValidator $predefinedTaskDescriptionValidator
* @property \Kanboard\Import\UserImport $userImport
* @property \Kanboard\Export\SubtaskExport $subtaskExport
* @property \Kanboard\Export\TaskExport $taskExport
* @property \Kanboard\Export\TransitionExport $transitionExport
* @property \Kanboard\Core\Filter\QueryBuilder $projectGroupRoleQuery
* @property \Kanboard\Core\Filter\QueryBuilder $projectUserRoleQuery
* @property \Kanboard\Core\Filter\QueryBuilder $projectActivityQuery
* @property \Kanboard\Core\Filter\QueryBuilder $userQuery
* @property \Kanboard\Core\Filter\QueryBuilder $projectQuery
* @property \Kanboard\Core\Filter\QueryBuilder $taskQuery
* @property \Kanboard\Core\Filter\LexerBuilder $taskLexer
* @property \Kanboard\Core\Filter\LexerBuilder $projectActivityLexer
* @property \Kanboard\Job\CommentEventJob $commentEventJob
* @property \Kanboard\Job\SubtaskEventJob $subtaskEventJob
* @property \Kanboard\Job\TaskEventJob $taskEventJob
* @property \Kanboard\Job\TaskFileEventJob $taskFileEventJob
* @property \Kanboard\Job\TaskLinkEventJob $taskLinkEventJob
* @property \Kanboard\Job\ProjectFileEventJob $projectFileEventJob
* @property \Kanboard\Job\NotificationJob $notificationJob
* @property \Kanboard\Job\ProjectMetricJob $projectMetricJob
* @property \Kanboard\Job\UserMentionJob $userMentionJob
* @property \Psr\Log\LoggerInterface $logger
* @property \PicoDb\Database $db
* @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher
* @property \Symfony\Component\Console\Application $cli
* @property \JsonRPC\Server $api
*/
abstract class Base
{
/**
* Container instance
*
* @access protected
* @var \Pimple\Container
*/
protected $container;
/**
* Constructor
*
* @access public
* @param \Pimple\Container $container
*/
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* Load automatically dependencies
*
* @access public
* @param string $name Class name
* @return mixed
*/
public function __get($name)
{
return $this->container[$name];
}
/**
* Get object instance
*
* @static
* @access public
* @param Container $container
* @return static
*/
public static function getInstance(Container $container)
{
return new static($container);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Kanboard\Core\Cache;
/**
* Base Class for Cache Drivers
*
* @package Kanboard\Core\Cache
* @author Frederic Guillot
*/
abstract class BaseCache implements CacheInterface
{
/**
* Proxy cache
*
* Note: Arguments must be scalar types
*
* @access public
* @param string $class Class instance
* @param string $method Container method
* @return mixed
*/
public function proxy($class, $method)
{
$args = func_get_args();
array_shift($args);
$key = 'proxy:'.get_class($class).':'.implode(':', $args);
$result = $this->get($key);
if ($result === null) {
$result = call_user_func_array(array($class, $method), array_splice($args, 1));
$this->set($key, $result);
}
return $result;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Kanboard\Core\Cache;
/**
* Interface CacheInterface
*
* @package Kanboard\Core\Cache
* @author Frederic Guillot
*/
interface CacheInterface
{
/**
* Store an item in the cache
*
* @access public
* @param string $key
* @param mixed $value
*/
public function set($key, $value);
/**
* Retrieve an item from the cache by key
*
* @access public
* @param string $key
* @return mixed Null when not found, cached value otherwise
*/
public function get($key);
/**
* Remove all items from the cache
*
* @access public
*/
public function flush();
/**
* Remove an item from the cache
*
* @access public
* @param string $key
*/
public function remove($key);
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Kanboard\Core\Cache;
/**
* Memory Cache Driver
*
* @package Kanboard\Core\Cache
* @author Frederic Guillot
*/
class MemoryCache extends BaseCache
{
/**
* Container
*
* @access private
* @var array
*/
private $storage = array();
/**
* Store an item in the cache
*
* @access public
* @param string $key
* @param mixed $value
*/
public function set($key, $value)
{
$this->storage[$key] = $value;
}
/**
* Retrieve an item from the cache by key
*
* @access public
* @param string $key
* @return mixed Null when not found, cached value otherwise
*/
public function get($key)
{
return isset($this->storage[$key]) ? $this->storage[$key] : null;
}
/**
* Clear all cache
*
* @access public
*/
public function flush()
{
$this->storage = array();
}
/**
* Remove cached value
*
* @access public
* @param string $key
*/
public function remove($key)
{
unset($this->storage[$key]);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Kanboard\Core\Controller;
/**
* Class AccessForbiddenException
*
* @package Kanboard\Core\Controller
* @author Frederic Guillot
*/
class AccessForbiddenException extends BaseException
{
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Kanboard\Core\Controller;
use Exception;
/**
* Class AccessForbiddenException
*
* @package Kanboard\Core\Controller
* @author Frederic Guillot
*/
class BaseException extends Exception
{
protected $withoutLayout = false;
/**
* Get object instance
*
* @static
* @access public
* @param string $message
* @return static
*/
public static function getInstance($message = '')
{
return new static($message);
}
/**
* There is no layout
*
* @access public
* @return BaseException
*/
public function withoutLayout()
{
$this->withoutLayout = true;
return $this;
}
/**
* Return true if no layout
*
* @access public
* @return boolean
*/
public function hasLayout()
{
return $this->withoutLayout;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Kanboard\Core\Controller;
use Kanboard\Core\Base;
/**
* Class BaseMiddleware
*
* @package Kanboard\Core\Controller
* @author Frederic Guillot
*/
abstract class BaseMiddleware extends Base
{
/**
* @var BaseMiddleware
*/
protected $nextMiddleware = null;
/**
* Execute middleware
*/
abstract public function execute();
/**
* Set next middleware
*
* @param BaseMiddleware $nextMiddleware
* @return BaseMiddleware
*/
public function setNextMiddleware(BaseMiddleware $nextMiddleware)
{
$this->nextMiddleware = $nextMiddleware;
return $this;
}
/**
* @return BaseMiddleware
*/
public function getNextMiddleware()
{
return $this->nextMiddleware;
}
/**
* Move to next middleware
*/
public function next()
{
if ($this->nextMiddleware !== null) {
if (DEBUG) {
$this->logger->debug(__METHOD__.' => ' . get_class($this->nextMiddleware));
}
$this->nextMiddleware->execute();
}
}
}
@@ -0,0 +1,14 @@
<?php
namespace Kanboard\Core\Controller;
/**
* Class PageNotFoundException
*
* @package Kanboard\Core\Controller
* @author Frederic Guillot
*/
class PageNotFoundException extends BaseException
{
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Kanboard\Core\Controller;
use Kanboard\Controller\AppController;
use Kanboard\Core\Base;
use Kanboard\Middleware\ApplicationAuthorizationMiddleware;
use Kanboard\Middleware\AuthenticationMiddleware;
use Kanboard\Middleware\BootstrapMiddleware;
use Kanboard\Middleware\PostAuthenticationMiddleware;
use Kanboard\Middleware\ProjectAuthorizationMiddleware;
use RuntimeException;
/**
* Class Runner
*
* @package Kanboard\Core\Controller
* @author Frederic Guillot
*/
class Runner extends Base
{
/**
* Execute middleware and controller
*/
public function execute()
{
try {
$this->executeMiddleware();
if (!$this->response->isResponseAlreadySent()) {
$this->executeController();
}
} catch (PageNotFoundException $e) {
$controllerObject = new AppController($this->container);
$controllerObject->notFound($e->hasLayout());
} catch (AccessForbiddenException $e) {
$controllerObject = new AppController($this->container);
$controllerObject->accessForbidden($e->hasLayout(), $e->getMessage());
}
}
/**
* Execute all middleware
*/
protected function executeMiddleware()
{
if (DEBUG) {
$this->logger->debug(__METHOD__);
}
$bootstrapMiddleware = new BootstrapMiddleware($this->container);
$authenticationMiddleware = new AuthenticationMiddleware($this->container);
$postAuthenticationMiddleware = new PostAuthenticationMiddleware($this->container);
$appAuthorizationMiddleware = new ApplicationAuthorizationMiddleware($this->container);
$projectAuthorizationMiddleware = new ProjectAuthorizationMiddleware($this->container);
$bootstrapMiddleware->setNextMiddleware($authenticationMiddleware);
$authenticationMiddleware->setNextMiddleware($postAuthenticationMiddleware);
$postAuthenticationMiddleware->setNextMiddleware($appAuthorizationMiddleware);
$appAuthorizationMiddleware->setNextMiddleware($projectAuthorizationMiddleware);
$bootstrapMiddleware->execute();
}
/**
* Execute the controller
*/
protected function executeController()
{
$className = $this->getControllerClassName();
if (DEBUG) {
$this->logger->debug(__METHOD__.' => '.$className.'::'.$this->router->getAction());
}
$controllerObject = new $className($this->container);
$controllerObject->{$this->router->getAction()}();
}
/**
* Get controller class name
*
* @access protected
* @return string
* @throws RuntimeException
*/
protected function getControllerClassName()
{
if ($this->router->getPlugin() !== '') {
$className = '\Kanboard\Plugin\\'.$this->router->getPlugin().'\Controller\\'.$this->router->getController();
} else {
$className = '\Kanboard\Controller\\'.$this->router->getController();
}
if (! class_exists($className)) {
throw new RuntimeException('Controller not found');
}
if (! method_exists($className, $this->router->getAction())) {
throw new RuntimeException('Action not implemented');
}
return $className;
}
}
+222
View File
@@ -0,0 +1,222 @@
<?php
namespace Kanboard\Core;
use SplFileObject;
/**
* CSV Writer/Reader
*
* @package core
* @author Frederic Guillot
*/
class Csv
{
/**
* CSV delimiter
*
* @access private
* @var string
*/
private $delimiter = ',';
/**
* CSV enclosure
*
* @access private
* @var string
*/
private $enclosure = '"';
/**
* CSV/SQL columns
*
* @access private
* @var array
*/
private $columns = array();
/**
* Constructor
*
* @access public
* @param string $delimiter
* @param string $enclosure
*/
public function __construct($delimiter = ',', $enclosure = '"')
{
$this->delimiter = $delimiter;
$this->enclosure = $enclosure;
}
/**
* Get list of delimiters
*
* @static
* @access public
* @return array
*/
public static function getDelimiters()
{
return array(
',' => t('Comma'),
';' => t('Semi-colon'),
'\t' => t('Tab'),
'|' => t('Vertical bar'),
);
}
/**
* Get list of enclosures
*
* @static
* @access public
* @return array
*/
public static function getEnclosures()
{
return array(
'"' => t('Double Quote'),
"'" => t('Single Quote'),
'' => t('None'),
);
}
/**
* Check boolean field value
*
* @static
* @access public
* @param mixed $value
* @return int
*/
public static function getBooleanValue($value)
{
if (! empty($value)) {
$value = trim(strtolower($value));
return $value === '1' || $value[0] === 't' || $value[0] === 'y' ? 1 : 0;
}
return 0;
}
/**
* Output CSV file to standard output
*
* @static
* @access public
* @param array $rows
* @param bool $addBOM
*/
public static function output(array $rows, $addBOM = false)
{
$csv = new static;
$csv->write('php://output', $rows, $addBOM);
}
/**
* Define column mapping between CSV and SQL columns
*
* @access public
* @param array $columns
* @return Csv
*/
public function setColumnMapping(array $columns)
{
$this->columns = $columns;
return $this;
}
/**
* Read CSV file
*
* @access public
* @param string $filename
* @param callable $callback Example: function(array $row, $line_number)
* @return Csv
*/
public function read($filename, $callback)
{
$file = new SplFileObject($filename);
$file->setFlags(SplFileObject::READ_CSV);
$file->setCsvControl($this->delimiter, $this->enclosure, '\\');
$line_number = 0;
foreach ($file as $row) {
$row = $this->filterRow($row);
if (! empty($row) && $line_number > 0) {
call_user_func_array($callback, array($this->associateColumns($row), $line_number));
}
$line_number++;
}
return $this;
}
/**
* Write CSV file
*
* @access public
* @param string $filename
* @param array $rows
* @param bool $addBOM
* @return Csv
*/
public function write($filename, array $rows, $addBOM = false)
{
$fp = fopen($filename, 'w');
if (is_resource($fp)) {
if ($addBOM) {
fwrite($fp, "\xEF\xBB\xBF");
}
foreach ($rows as $row) {
fputcsv($fp, $row, $this->delimiter, $this->enclosure, '\\');
}
fclose($fp);
}
return $this;
}
/**
* Associate columns header with row values
*
* @access private
* @param array $row
* @return array
*/
private function associateColumns(array $row)
{
$line = array();
$index = 0;
foreach ($this->columns as $sql_name => $csv_name) {
if (isset($row[$index])) {
$line[$sql_name] = $row[$index];
} else {
$line[$sql_name] = '';
}
$index++;
}
return $line;
}
/**
* Filter empty rows
*
* @access private
* @param array $row
* @return array
*/
private function filterRow(array $row)
{
return array_filter($row);
}
}
+326
View File
@@ -0,0 +1,326 @@
<?php
namespace Kanboard\Core;
use DateTime;
/**
* Date Parser
*
* @package core
* @author Frederic Guillot
*/
class DateParser extends Base
{
const DATE_FORMAT = 'm/d/Y';
const TIME_FORMAT = 'H:i';
/**
* Get date format from settings
*
* @access public
* @return string
*/
public function getUserDateFormat()
{
return $this->configModel->get('application_date_format', DateParser::DATE_FORMAT);
}
/**
* Get date time format from settings
*
* @access public
* @return string
*/
public function getUserDateTimeFormat()
{
return $this->getUserDateFormat().' '.$this->getUserTimeFormat();
}
/**
* Get time format from settings
*
* @access public
* @return string
*/
public function getUserTimeFormat()
{
return $this->configModel->get('application_time_format', DateParser::TIME_FORMAT);
}
/**
* List of time formats
*
* @access public
* @return string[]
*/
public function getTimeFormats()
{
return array(
'H:i',
'g:i a',
);
}
/**
* List of date formats
*
* @access public
* @param boolean $iso
* @return string[]
*/
public function getDateFormats($iso = false)
{
$formats = array(
$this->getUserDateFormat(),
);
$isoFormats = array(
'Y-m-d',
'Y_m_d',
);
$userFormats = array(
'm/d/Y',
'd/m/Y',
'Y/m/d',
'd.m.Y',
);
if ($iso) {
$formats = array_merge($formats, $isoFormats, $userFormats);
} else {
$formats = array_merge($formats, $userFormats);
}
return array_unique($formats);
}
/**
* List of datetime formats
*
* @access public
* @param boolean $iso
* @return string[]
*/
public function getDateTimeFormats($iso = false)
{
$formats = array(
$this->getUserDateTimeFormat(),
);
foreach ($this->getDateFormats($iso) as $date) {
foreach ($this->getTimeFormats() as $time) {
$formats[] = $date.' '.$time;
}
}
return array_unique($formats);
}
/**
* List of all date formats
*
* @access public
* @param boolean $iso
* @return string[]
*/
public function getAllDateFormats($iso = false)
{
return array_merge($this->getDateFormats($iso), $this->getDateTimeFormats($iso));
}
/**
* Get available formats (visible in settings)
*
* @access public
* @param array $formats
* @return array
*/
public function getAvailableFormats(array $formats)
{
$values = array();
foreach ($formats as $format) {
$values[$format] = date($format).' ('.$format.')';
}
return $values;
}
/**
* Get formats for date parsing
*
* @access public
* @return array
*/
public function getParserFormats()
{
return array(
$this->getUserDateFormat(),
'Y-m-d',
'Y_m_d',
$this->getUserDateTimeFormat(),
'Y-m-d H:i',
'Y_m_d H:i',
);
}
/**
* Parse a date and return a unix timestamp, try different date formats
*
* @access public
* @param string $value Date to parse
* @return integer
*/
public function getTimestamp($value)
{
if (ctype_digit((string) $value)) {
return (int) $value;
}
foreach ($this->getParserFormats() as $format) {
$timestamp = $this->getValidDate($value, $format);
if ($timestamp !== 0) {
return $timestamp;
}
}
return 0;
}
/**
* Return a timestamp if the given date format is correct otherwise return 0
*
* @access private
* @param string $value Date to parse
* @param string $format Date format
* @return integer
*/
private function getValidDate($value, $format)
{
$date = DateTime::createFromFormat($format, $value);
if ($date !== false) {
$errors = DateTime::getLastErrors();
if ($errors === false ||
$errors['error_count'] === 0 && $errors['warning_count'] === 0) {
$timestamp = $date->getTimestamp();
return $timestamp > 0 ? $timestamp : 0;
}
}
return 0;
}
/**
* Return true if the date is within the date range
*
* @access public
* @param DateTime $date
* @param DateTime $start
* @param DateTime $end
* @return boolean
*/
public function withinDateRange(DateTime $date, DateTime $start, DateTime $end)
{
return $date >= $start && $date <= $end;
}
/**
* Get the total number of hours between 2 datetime objects
* Minutes are rounded to the nearest quarter
*
* @access public
* @param DateTime $d1
* @param DateTime $d2
* @return float
*/
public function getHours(DateTime $d1, DateTime $d2)
{
$seconds = abs($d1->getTimestamp() - $d2->getTimestamp());
return round($seconds / 3600, 2);
}
/**
* Get ISO-8601 date from user input
*
* @access public
* @param string $value Date to parse
* @return string
*/
public function getIsoDate($value)
{
return date('Y-m-d', $this->getTimestamp($value));
}
/**
* Get a timestamp from an ISO date format
*
* @access public
* @param string $value
* @return integer
*/
public function getTimestampFromIsoFormat($value)
{
return $this->removeTimeFromTimestamp(ctype_digit((string) $value) ? $value : strtotime($value));
}
/**
* Remove the time from a timestamp
*
* @access public
* @param integer $timestamp
* @return integer
*/
public function removeTimeFromTimestamp($timestamp)
{
return mktime(0, 0, 0, date('m', $timestamp), date('d', $timestamp), date('Y', $timestamp));
}
/**
* Format date (form display)
*
* @access public
* @param array $values Database values
* @param string[] $fields Date fields
* @param string $format Date format
* @return array
*/
public function format(array $values, array $fields, $format)
{
foreach ($fields as $field) {
if (! empty($values[$field])) {
if (ctype_digit((string) $values[$field])) {
$values[$field] = date($format, $values[$field]);
}
} else {
$values[$field] = '';
}
}
return $values;
}
/**
* Convert date to timestamp
*
* @access public
* @param array $values Database values
* @param string[] $fields Date fields
* @param boolean $keep_time Keep time or not
* @return array
*/
public function convert(array $values, array $fields, $keep_time = false)
{
foreach ($fields as $field) {
if (! empty($values[$field])) {
$timestamp = $this->getTimestamp($values[$field]);
$values[$field] = $keep_time ? $timestamp : $this->removeTimeFromTimestamp($timestamp);
}
}
return $values;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Kanboard\Core\Event;
use Kanboard\Model\TaskModel;
use Kanboard\Model\SubtaskModel;
use Kanboard\Model\TaskLinkModel;
/**
* Event Manager
*
* @package event
* @author Frederic Guillot
*/
class EventManager
{
/**
* Extended events
*
* @access private
* @var array
*/
private $events = array();
/**
* Add new event
*
* @access public
* @param string $event
* @param string $description
* @return EventManager
*/
public function register($event, $description)
{
$this->events[$event] = $description;
return $this;
}
/**
* Get the list of events and description that can be used from the user interface
*
* @access public
* @return array
*/
public function getAll()
{
$events = array(
TaskLinkModel::EVENT_CREATE_UPDATE => t('Task link creation or modification'),
TaskModel::EVENT_MOVE_COLUMN => t('Move a task to another column'),
TaskModel::EVENT_UPDATE => t('Task modification'),
TaskModel::EVENT_CREATE => t('Task creation'),
TaskModel::EVENT_OPEN => t('Reopen a task'),
TaskModel::EVENT_CLOSE => t('Closing a task'),
TaskModel::EVENT_CREATE_UPDATE => t('Task creation or modification'),
TaskModel::EVENT_ASSIGNEE_CHANGE => t('Task assignee change'),
TaskModel::EVENT_DAILY_CRONJOB => t('Daily background job for tasks'),
TaskModel::EVENT_MOVE_SWIMLANE => t('Move a task to another swimlane'),
SubtaskModel::EVENT_CREATE_UPDATE => t('Subtask creation or modification'),
);
$events = array_merge($events, $this->events);
asort($events);
return $events;
}
}
@@ -0,0 +1,36 @@
<?php
namespace Kanboard\Core\ExternalLink;
/**
* External Link Interface
*
* @package externalLink
* @author Frederic Guillot
*/
interface ExternalLinkInterface
{
/**
* Get link title
*
* @access public
* @return string
*/
public function getTitle();
/**
* Get link URL
*
* @access public
* @return string
*/
public function getUrl();
/**
* Set link URL
*
* @access public
* @param string $url
*/
public function setUrl($url);
}
@@ -0,0 +1,197 @@
<?php
namespace Kanboard\Core\ExternalLink;
use Kanboard\Core\Base;
/**
* External Link Manager
*
* @package externalLink
* @author Frederic Guillot
*/
class ExternalLinkManager extends Base
{
/**
* Automatic type value
*
* @var string
*/
const TYPE_AUTO = 'auto';
/**
* Registered providers
*
* @access private
* @var ExternalLinkProviderInterface[]
*/
private $providers = array();
/**
* Type chosen by the user
*
* @access private
* @var string
*/
private $userInputType = '';
/**
* Text entered by the user
*
* @access private
* @var string
*/
private $userInputText = '';
/**
* Register a new provider
*
* Providers are registered in a LIFO queue
*
* @access public
* @param ExternalLinkProviderInterface $provider
* @return ExternalLinkManager
*/
public function register(ExternalLinkProviderInterface $provider)
{
array_unshift($this->providers, $provider);
return $this;
}
/**
* Get provider
*
* @access public
* @param string $type
* @throws ExternalLinkProviderNotFound
* @return ExternalLinkProviderInterface
*/
public function getProvider($type)
{
foreach ($this->providers as $provider) {
if ($provider->getType() === $type) {
return $provider;
}
}
throw new ExternalLinkProviderNotFound('Unable to find link provider: '.$type);
}
/**
* Get link types
*
* @access public
* @return array
*/
public function getTypes()
{
$types = array();
foreach ($this->providers as $provider) {
$types[$provider->getType()] = $provider->getName();
}
asort($types);
return array(self::TYPE_AUTO => t('Auto')) + $types;
}
/**
* Get dependency label from a provider
*
* @access public
* @param string $type
* @param string $dependency
* @return string
*/
public function getDependencyLabel($type, $dependency)
{
$provider = $this->getProvider($type);
$dependencies = $provider->getDependencies();
return isset($dependencies[$dependency]) ? $dependencies[$dependency] : $dependency;
}
/**
* Find a provider that match
*
* @access public
* @throws ExternalLinkProviderNotFound
* @return ExternalLinkProviderInterface
*/
public function find()
{
if ($this->userInputType === self::TYPE_AUTO) {
$provider = $this->findProvider();
} else {
$provider = $this->getProvider($this->userInputType);
$provider->setUserTextInput($this->userInputText);
if (! $provider->match()) {
throw new ExternalLinkProviderNotFound('Unable to parse URL with selected provider');
}
}
if ($provider === null) {
throw new ExternalLinkProviderNotFound('Unable to find link information from provided information');
}
return $provider;
}
/**
* Set form values
*
* @access public
* @param array $values
* @return ExternalLinkManager
*/
public function setUserInput(array $values)
{
$this->userInputType = empty($values['type']) ? self::TYPE_AUTO : $values['type'];
$this->userInputText = empty($values['text']) ? '' : trim($values['text']);
return $this;
}
/**
* Set provider type
*
* @access public
* @param string $userInputType
* @return ExternalLinkManager
*/
public function setUserInputType($userInputType)
{
$this->userInputType = $userInputType;
return $this;
}
/**
* Set external link
* @param string $userInputText
* @return ExternalLinkManager
*/
public function setUserInputText($userInputText)
{
$this->userInputText = $userInputText;
return $this;
}
/**
* Find a provider that user input
*
* @access private
* @return ExternalLinkProviderInterface
*/
private function findProvider()
{
foreach ($this->providers as $provider) {
$provider->setUserTextInput($this->userInputText);
if ($provider->match()) {
return $provider;
}
}
return null;
}
}
@@ -0,0 +1,71 @@
<?php
namespace Kanboard\Core\ExternalLink;
/**
* External Link Provider Interface
*
* @package externalLink
* @author Frederic Guillot
*/
interface ExternalLinkProviderInterface
{
/**
* Get provider name (label)
*
* @access public
* @return string
*/
public function getName();
/**
* Get link type (will be saved in the database)
*
* @access public
* @return string
*/
public function getType();
/**
* Get a dictionary of supported dependency types by the provider
*
* Example:
*
* [
* 'related' => 'Related',
* 'child' => 'Child',
* 'parent' => 'Parent',
* 'self' => 'Self',
* ]
*
* The dictionary key is saved in the database.
*
* @access public
* @return array
*/
public function getDependencies();
/**
* Set text entered by the user
*
* @access public
* @param string $input
*/
public function setUserTextInput($input);
/**
* Return true if the provider can parse correctly the user input
*
* @access public
* @return boolean
*/
public function match();
/**
* Get the link found with the properties
*
* @access public
* @return ExternalLinkInterface
*/
public function getLink();
}
@@ -0,0 +1,15 @@
<?php
namespace Kanboard\Core\ExternalLink;
use Exception;
/**
* External Link Provider Not Found Exception
*
* @package externalLink
* @author Frederic Guillot
*/
class ExternalLinkProviderNotFound extends Exception
{
}
@@ -0,0 +1,13 @@
<?php
namespace Kanboard\Core\ExternalTask;
/**
* Class AccessForbiddenException
*
* @package Kanboard\Core\ExternalTask
* @author Frederic Guillot
*/
class AccessForbiddenException extends ExternalTaskException
{
}
@@ -0,0 +1,15 @@
<?php
namespace Kanboard\Core\ExternalTask;
use Exception;
/**
* Class NotFoundException
*
* @package Kanboard\Core\ExternalTask
* @author Frederic Guillot
*/
class ExternalTaskException extends Exception
{
}
@@ -0,0 +1,26 @@
<?php
namespace Kanboard\Core\ExternalTask;
/**
* Interface ExternalTaskInterface
*
* @package Kanboard\Core\ExternalTask
* @author Frederic Guillot
*/
interface ExternalTaskInterface
{
/**
* Return Uniform Resource Identifier for the task
*
* @return string
*/
public function getUri();
/**
* Return a dict to populate the task form
*
* @return array
*/
public function getFormValues();
}
@@ -0,0 +1,68 @@
<?php
namespace Kanboard\Core\ExternalTask;
/**
* Class ExternalTaskManager
*
* @package Kanboard\Core\ExternalTask
* @author Frederic Guillot
*/
class ExternalTaskManager
{
protected $providers = array();
/**
* Register a new task provider
*
* @param ExternalTaskProviderInterface $externalTaskProvider
* @return $this
*/
public function register(ExternalTaskProviderInterface $externalTaskProvider)
{
$this->providers[$externalTaskProvider->getName()] = $externalTaskProvider;
return $this;
}
/**
* Get task provider
*
* @param string $name
* @return ExternalTaskProviderInterface|null
* @throws ProviderNotFoundException
*/
public function getProvider($name)
{
if (isset($this->providers[$name])) {
return $this->providers[$name];
}
throw new ProviderNotFoundException('Unable to load this provider: '.$name);
}
/**
* Get list of task providers
*
* @return array
*/
public function getProvidersList()
{
$providers = array_keys($this->providers);
if (count($providers)) {
return array_combine($providers, $providers);
}
return array();
}
/**
* Get all providers
*
* @return ExternalTaskProviderInterface[]
*/
public function getProviders()
{
return $this->providers;
}
}
@@ -0,0 +1,94 @@
<?php
namespace Kanboard\Core\ExternalTask;
/**
* Interface ExternalTaskProviderInterface
*
* @package Kanboard\Core\ExternalTask
* @author Frederic Guillot
*/
interface ExternalTaskProviderInterface
{
/**
* Get provider name (visible in the user interface)
*
* @access public
* @return string
*/
public function getName();
/**
* Get provider icon
*
* @access public
* @return string
*/
public function getIcon();
/**
* Get label for adding a new task
*
* @access public
* @return string
*/
public function getMenuAddLabel();
/**
* Retrieve task from external system or cache
*
* @access public
* @throws \Kanboard\Core\ExternalTask\ExternalTaskException
* @param string $uri
* @param int $projectID
* @return ExternalTaskInterface
*/
public function fetch($uri, $projectID);
/**
* Save external task to another system
*
* @throws \Kanboard\Core\ExternalTask\ExternalTaskException
* @param string $uri
* @param array $formValues
* @param array $formErrors
* @return bool
*/
public function save($uri, array $formValues, array &$formErrors);
/**
* Get task import template name
*
* @return string
*/
public function getImportFormTemplate();
/**
* Get creation form template
*
* @return string
*/
public function getCreationFormTemplate();
/**
* Get modification form template
*
* @return string
*/
public function getModificationFormTemplate();
/**
* Get task view template name
*
* @return string
*/
public function getViewTemplate();
/**
* Build external task URI based on import form values
*
* @param array $formValues
* @return string
*/
public function buildTaskUri(array $formValues);
}
@@ -0,0 +1,13 @@
<?php
namespace Kanboard\Core\ExternalTask;
/**
* Class NotFoundException
*
* @package Kanboard\Core\ExternalTask
* @author Frederic Guillot
*/
class NotFoundException extends ExternalTaskException
{
}
@@ -0,0 +1,13 @@
<?php
namespace Kanboard\Core\ExternalTask;
/**
* Class ProviderNotFoundException
*
* @package Kanboard\Core\ExternalTask
* @author Frederic Guillot
*/
class ProviderNotFoundException extends ExternalTaskException
{
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Kanboard\Core\Filter;
use PicoDb\Table;
/**
* Criteria Interface
*
* @package filter
* @author Frederic Guillot
*/
interface CriteriaInterface
{
/**
* Set the Query
*
* @access public
* @param Table $query
* @return CriteriaInterface
*/
public function withQuery(Table $query);
/**
* Set filter
*
* @access public
* @param FilterInterface $filter
* @return CriteriaInterface
*/
public function withFilter(FilterInterface $filter);
/**
* Apply condition
*
* @access public
* @return CriteriaInterface
*/
public function apply();
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Kanboard\Core\Filter;
use PicoDb\Table;
/**
* Filter Interface
*
* @package filter
* @author Frederic Guillot
*/
interface FilterInterface
{
/**
* BaseFilter constructor
*
* @access public
* @param mixed $value
*/
public function __construct($value = null);
/**
* Set the value
*
* @access public
* @param string $value
* @return FilterInterface
*/
public function withValue($value);
/**
* Set query
*
* @access public
* @param Table $query
* @return FilterInterface
*/
public function withQuery(Table $query);
/**
* Get search attribute
*
* @access public
* @return string[]
*/
public function getAttributes();
/**
* Apply filter
*
* @access public
* @return FilterInterface
*/
public function apply();
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Kanboard\Core\Filter;
use PicoDb\Table;
/**
* Formatter interface
*
* @package filter
* @author Frederic Guillot
*/
interface FormatterInterface
{
/**
* Set query
*
* @access public
* @param Table $query
* @return $this
*/
public function withQuery(Table $query);
/**
* Apply formatter
*
* @access public
* @return mixed
*/
public function format();
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace Kanboard\Core\Filter;
/**
* Lexer
*
* @package filter
* @author Frederic Guillot
*/
class Lexer
{
/**
* Current position
*
* @access private
* @var integer
*/
private $offset = 0;
/**
* Token map
*
* @access private
* @var array
*/
private $tokenMap = array(
'/^(\s+)/' => 'T_WHITESPACE',
'/^([<=>]{0,2}[0-9]{4}-[0-9]{2}-[0-9]{2})/' => 'T_STRING',
'/^([<=>]{1,2}\w+)/u' => 'T_STRING',
'/^([<=>]{1,2}".+")/' => 'T_STRING',
'/^("(.*?)")/' => 'T_STRING',
'/^(\S+)/u' => 'T_STRING',
'/^(#\d+)/' => 'T_STRING',
);
/**
* Default token
*
* @access private
* @var string
*/
private $defaultToken = '';
/**
* Add token
*
* @access public
* @param string $regex
* @param string $token
* @return $this
*/
public function addToken($regex, $token)
{
$this->tokenMap = array($regex => $token) + $this->tokenMap;
return $this;
}
/**
* Set default token
*
* @access public
* @param string $token
* @return $this
*/
public function setDefaultToken($token)
{
$this->defaultToken = $token;
return $this;
}
/**
* Tokenize input string
*
* @access public
* @param string $input
* @return array
*/
public function tokenize($input)
{
$tokens = array();
$this->offset = 0;
if (is_null($input)) {
$input = "";
}
$input_length = mb_strlen($input, 'UTF-8');
while ($this->offset < $input_length) {
$result = $this->match(mb_substr($input, $this->offset, $input_length, 'UTF-8'));
if ($result === false) {
return array();
}
$tokens[] = $result;
}
return $this->map($tokens);
}
/**
* Find a token that match and move the offset
*
* @access protected
* @param string $string
* @return array|boolean
*/
protected function match($string)
{
foreach ($this->tokenMap as $pattern => $name) {
if (preg_match($pattern, $string, $matches)) {
$this->offset += mb_strlen($matches[1], 'UTF-8');
return array(
'match' => str_replace('"', '', $matches[1]),
'token' => $name,
);
}
}
return false;
}
/**
* Build map of tokens and matches
*
* @access protected
* @param array $tokens
* @return array
*/
protected function map(array $tokens)
{
$map = array();
$leftOver = '';
while (false !== ($token = current($tokens))) {
if ($token['token'] === 'T_STRING' || $token['token'] === 'T_WHITESPACE') {
$leftOver .= $token['match'];
} else {
$next = next($tokens);
if ($next !== false && $next['token'] === 'T_STRING') {
$map[$token['token']][] = $next['match'];
}
}
next($tokens);
}
$leftOver = trim($leftOver);
if ($this->defaultToken !== '' && $leftOver !== '') {
$map[$this->defaultToken] = array($leftOver);
}
return $map;
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Kanboard\Core\Filter;
use PicoDb\Table;
/**
* Lexer Builder
*
* @package filter
* @author Frederic Guillot
*/
class LexerBuilder
{
/**
* Lexer object
*
* @access protected
* @var Lexer
*/
protected $lexer;
/**
* Query object
*
* @access protected
* @var Table
*/
protected $query;
/**
* List of filters
*
* @access protected
* @var FilterInterface[]
*/
protected $filters;
/**
* QueryBuilder object
*
* @access protected
* @var QueryBuilder
*/
protected $queryBuilder;
/**
* Constructor
*
* @access public
*/
public function __construct()
{
$this->lexer = new Lexer();
$this->queryBuilder = new QueryBuilder();
}
/**
* Add a filter
*
* @access public
* @param FilterInterface $filter
* @param bool $default
* @return LexerBuilder
*/
public function withFilter(FilterInterface $filter, $default = false)
{
$attributes = $filter->getAttributes();
foreach ($attributes as $attribute) {
$this->filters[$attribute] = $filter;
$this->lexer->addToken(sprintf("/^(%s:)/i", $attribute), $attribute);
if ($default) {
$this->lexer->setDefaultToken($attribute);
}
}
return $this;
}
/**
* Set the query
*
* @access public
* @param Table $query
* @return LexerBuilder
*/
public function withQuery(Table $query)
{
$this->query = $query;
$this->queryBuilder->withQuery($this->query);
return $this;
}
/**
* Parse the input and build the query
*
* @access public
* @param string $input
* @return QueryBuilder
*/
public function build($input)
{
$tokens = $this->lexer->tokenize($input);
foreach ($tokens as $token => $values) {
if (isset($this->filters[$token])) {
$this->applyFilters($this->filters[$token], $values);
}
}
return $this->queryBuilder;
}
/**
* Apply filters to the query
*
* @access protected
* @param FilterInterface $filter
* @param array $values
*/
protected function applyFilters(FilterInterface $filter, array $values)
{
$len = count($values);
if ($len > 1) {
$criteria = new OrCriteria();
$criteria->withQuery($this->query);
foreach ($values as $value) {
$currentFilter = clone($filter);
$criteria->withFilter($currentFilter->withValue($value));
}
$this->queryBuilder->withCriteria($criteria);
} elseif ($len === 1) {
$this->queryBuilder->withFilter($filter->withValue($values[0]));
}
}
/**
* Clone object with deep copy
*/
public function __clone()
{
$this->lexer = clone $this->lexer;
$this->query = clone $this->query;
$this->queryBuilder = clone $this->queryBuilder;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Kanboard\Core\Filter;
use PicoDb\Table;
/**
* OR criteria
*
* @package filter
* @author Frederic Guillot
*/
class OrCriteria implements CriteriaInterface
{
/**
* @var Table
*/
protected $query;
/**
* @var FilterInterface[]
*/
protected $filters = array();
/**
* Set the Query
*
* @access public
* @param Table $query
* @return CriteriaInterface
*/
public function withQuery(Table $query)
{
$this->query = $query;
return $this;
}
/**
* Set filter
*
* @access public
* @param FilterInterface $filter
* @return CriteriaInterface
*/
public function withFilter(FilterInterface $filter)
{
$this->filters[] = $filter;
return $this;
}
/**
* Apply condition
*
* @access public
* @return CriteriaInterface
*/
public function apply()
{
$this->query->beginOr();
foreach ($this->filters as $filter) {
$filter->withQuery($this->query)->apply();
}
$this->query->closeOr();
return $this;
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Kanboard\Core\Filter;
use PicoDb\Table;
/**
* Class QueryBuilder
*
* @package filter
* @author Frederic Guillot
*/
class QueryBuilder
{
/**
* Query object
*
* @access protected
* @var Table
*/
protected $query;
/**
* Create a new class instance
*
* @static
* @access public
* @return static
*/
public static function create()
{
return new static();
}
/**
* Set the query
*
* @access public
* @param Table $query
* @return QueryBuilder
*/
public function withQuery(Table $query)
{
$this->query = $query;
return $this;
}
/**
* Set a filter
*
* @access public
* @param FilterInterface $filter
* @return QueryBuilder
*/
public function withFilter(FilterInterface $filter)
{
$filter->withQuery($this->query)->apply();
return $this;
}
/**
* Set a criteria
*
* @access public
* @param CriteriaInterface $criteria
* @return QueryBuilder
*/
public function withCriteria(CriteriaInterface $criteria)
{
$criteria->withQuery($this->query)->apply();
return $this;
}
/**
* Set a formatter
*
* @access public
* @param FormatterInterface $formatter
* @return string|array
*/
public function format(FormatterInterface $formatter)
{
return $formatter->withQuery($this->query)->format();
}
/**
* Get the query result as array
*
* @access public
* @return array
*/
public function toArray()
{
return $this->query->findAll();
}
/**
* Get Query object
*
* @access public
* @return Table
*/
public function getQuery()
{
return $this->query;
}
/**
* Clone object with deep copy
*/
public function __clone()
{
$this->query = clone $this->query;
}
}
@@ -0,0 +1,21 @@
<?php
namespace Kanboard\Core\Group;
/**
* Group Backend Provider Interface
*
* @package Kanboard\Core\Group
* @author Frederic Guillot
*/
interface GroupBackendProviderInterface
{
/**
* Find a group from a search query
*
* @access public
* @param string $input
* @return GroupProviderInterface[]
*/
public function find($input);
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace Kanboard\Core\Group;
/**
* Group Manager
*
* @package Kanboard\Core\Group
* @author Frederic Guillot
*/
class GroupManager
{
/**
* List of backend providers
*
* @access protected
* @var array
*/
protected $providers = array();
/**
* Register a new group backend provider
*
* @access public
* @param GroupBackendProviderInterface $provider
* @return GroupManager
*/
public function register(GroupBackendProviderInterface $provider)
{
$this->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 protected
* @param array $groups
* @return GroupProviderInterface[]
*/
protected function removeDuplicates(array $groups)
{
$result = array();
foreach ($groups as $group) {
if (! isset($result[$group->getName()])) {
$result[$group->getName()] = $group;
}
}
return array_values($result);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Kanboard\Core\Group;
/**
* Group Provider Interface
*
* @package Kanboard\Core\Group
* @author Frederic Guillot
*/
interface GroupProviderInterface
{
/**
* Get internal id
*
* You must return 0 if the group come from an external backend
*
* @access public
* @return integer
*/
public function getInternalId();
/**
* Get external id
*
* You must return a unique id if the group come from an external provider
*
* @access public
* @return string
*/
public function getExternalId();
/**
* Get group name
*
* @access public
* @return string
*/
public function getName();
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace Kanboard\Core;
use Pimple\Container;
/**
* Helper base class
*
* @package core
* @author Frederic Guillot
*
* @property \Kanboard\Helper\AppHelper $app
* @property \Kanboard\Helper\AssetHelper $asset
* @property \Kanboard\Helper\AvatarHelper $avatar
* @property \Kanboard\Helper\BoardHelper $board
* @property \Kanboard\Helper\CommentHelper $comment
* @property \Kanboard\Helper\DateHelper $dt
* @property \Kanboard\Helper\FileHelper $file
* @property \Kanboard\Helper\FormHelper $form
* @property \Kanboard\Helper\HookHelper $hook
* @property \Kanboard\Helper\ModalHelper $modal
* @property \Kanboard\Helper\ModelHelper $model
* @property \Kanboard\Helper\SubtaskHelper $subtask
* @property \Kanboard\Helper\TaskHelper $task
* @property \Kanboard\Helper\TextHelper $text
* @property \Kanboard\Helper\UrlHelper $url
* @property \Kanboard\Helper\UserHelper $user
* @property \Kanboard\Helper\LayoutHelper $layout
* @property \Kanboard\Helper\ProjectRoleHelper $projectRole
* @property \Kanboard\Helper\ProjectHeaderHelper $projectHeader
* @property \Kanboard\Helper\ProjectActivityHelper $projectActivity
* @property \Kanboard\Helper\MailHelper $mail
*/
class Helper
{
/**
* Helper instances
*
* @access private
* @var \Pimple\Container
*/
private $helpers;
/**
* Container instance
*
* @access private
* @var \Pimple\Container
*/
private $container;
/**
* Constructor
*
* @access public
* @param \Pimple\Container $container
*/
public function __construct(Container $container)
{
$this->container = $container;
$this->helpers = new Container;
}
/**
* Expose helpers with magic getter
*
* @access public
* @param string $helper
* @return mixed
*/
public function __get($helper)
{
return $this->getHelper($helper);
}
/**
* Allow overriding helpers through magic setter
*
* @access public
* @param string $helper
* @param mixed $instance
*/
public function __set($helper, $instance)
{
$this->helpers[$helper] = $instance;
}
/**
* Expose helpers with method
*
* @access public
* @param string $helper
* @return mixed
*/
public function getHelper($helper)
{
return $this->helpers[$helper];
}
/**
* Register a new Helper
*
* @access public
* @param string $property
* @param string $className
* @return Helper
*/
public function register($property, $className)
{
$container = $this->container;
$this->helpers[$property] = function () use ($className, $container) {
return new $className($container);
};
return $this;
}
}
+451
View File
@@ -0,0 +1,451 @@
<?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;
}
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 = trim($parsedUrl['host']);
if ($host === '') {
return false;
}
$ipv4Address = gethostbyname($host);
if ($this->isPrivateIpAddress($ipv4Address)) {
return true;
}
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'])) {
if ($this->isPrivateIpAddress($record['ipv6'])) {
return true;
}
}
}
}
}
return false;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace Kanboard\Core\Http;
use Exception;
class ClientException extends Exception
{
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Kanboard\Core\Http;
class InvalidStatusException extends ClientException
{
protected $statusCode = 0;
protected $body = '';
public function __construct($message, $statusCode, $body)
{
parent::__construct($message);
$this->statusCode = $statusCode;
$this->body = $body;
}
public function getStatusCode()
{
return $this->statusCode;
}
public function getBody()
{
return $this->body;
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
namespace Kanboard\Core\Http;
use Kanboard\Core\Base;
/**
* OAuth2 Client
*
* @package http
* @author Frederic Guillot
*/
class OAuth2 extends Base
{
protected $clientId;
protected $secret;
protected $callbackUrl;
protected $authUrl;
protected $tokenUrl;
protected $scopes;
protected $tokenType;
protected $accessToken;
/**
* Create OAuth2 service
*
* @access public
* @param string $clientId
* @param string $secret
* @param string $callbackUrl
* @param string $authUrl
* @param string $tokenUrl
* @param array $scopes
* @return OAuth2
*/
public function createService($clientId, $secret, $callbackUrl, $authUrl, $tokenUrl, array $scopes)
{
$this->clientId = $clientId;
$this->secret = $secret;
$this->callbackUrl = $callbackUrl;
$this->authUrl = $authUrl;
$this->tokenUrl = $tokenUrl;
$this->scopes = $scopes;
return $this;
}
/**
* Generate OAuth2 state and return the token value
*
* @access public
* @return string
*/
public function getState()
{
if (! session_exists('oauthState')) {
session_set('oauthState', $this->token->getToken());
}
return session_get('oauthState');
}
/**
* Check the validity of the state (CSRF token)
*
* @access public
* @param string $state
* @return bool
*/
public function isValidateState($state)
{
return $state === $this->getState();
}
/**
* 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),
'state' => $this->getState(),
);
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',
'state' => $this->getState(),
);
$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 $this
*/
public function setAccessToken($token, $type = 'bearer')
{
$this->accessToken = $token;
$this->tokenType = $type;
return $this;
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace Kanboard\Core\Http;
use Kanboard\Core\Base;
/**
* Remember Me Cookie
*
* @package http
* @author Frederic Guillot
*/
class RememberMeCookie extends Base
{
/**
* Cookie name
*
* @var string
*/
const COOKIE_NAME = 'KB_RM';
/**
* Encode the cookie
*
* @access public
* @param string $token Session token
* @param string $sequence Sequence token
* @return string
*/
public function encode($token, $sequence)
{
return implode('|', array($token, $sequence));
}
/**
* Decode the value of a cookie
*
* @access public
* @param string $value Raw cookie data
* @return array
*/
public function decode($value)
{
list($token, $sequence) = explode('|', $value);
return array(
'token' => $token,
'sequence' => $sequence,
);
}
/**
* Return true if the current user has a RememberMe cookie
*
* @access public
* @return bool
*/
public function hasCookie()
{
return $this->request->getCookie(self::COOKIE_NAME) !== '';
}
/**
* Write and encode the cookie
*
* @access public
* @param string $token Session token
* @param string $sequence Sequence token
* @param string $expiration Cookie expiration
* @return boolean
*/
public function write($token, $sequence, $expiration)
{
return setcookie(
self::COOKIE_NAME,
$this->encode($token, $sequence),
$expiration,
$this->helper->url->dir(),
'',
$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(),
'',
$this->request->isHTTPS(),
true
);
}
}
+596
View File
@@ -0,0 +1,596 @@
<?php
namespace Kanboard\Core\Http;
use Pimple\Container;
use Kanboard\Core\Base;
/**
* Request class
*
* @package http
* @author Frederic Guillot
*/
class Request extends Base
{
/**
* Pointer to PHP environment variables
*
* @access private
* @var array
*/
private $server;
private $get;
private $post;
private $files;
private $cookies;
/**
* Constructor
*
* @access public
* @param \Pimple\Container $container
* @param array $server
* @param array $get
* @param array $post
* @param array $files
* @param array $cookies
*/
public function __construct(Container $container, array $server = array(), array $get = array(), array $post = array(), array $files = array(), array $cookies = array())
{
parent::__construct($container);
$this->server = empty($server) ? $_SERVER : $server;
$this->get = empty($get) ? $_GET : $get;
$this->post = empty($post) ? $_POST : $post;
$this->files = empty($files) ? $_FILES : $files;
$this->cookies = empty($cookies) ? $_COOKIE : $cookies;
}
/**
* Set GET parameters
*
* @param array $params
*/
public function setParams(array $params)
{
$this->get = array_merge($this->get, $params);
}
/**
* Get query string string parameter
*
* @access public
* @param string $name Parameter name
* @param string $default_value Default value
* @return string
*/
public function getStringParam($name, $default_value = '')
{
return isset($this->get[$name]) ? $this->get[$name] : $default_value;
}
/**
* Get query string integer parameter
*
* @access public
* @param string $name Parameter name
* @param integer $default_value Default value
* @return integer
*/
public function getIntegerParam($name, $default_value = 0)
{
return isset($this->get[$name]) && ctype_digit((string) $this->get[$name]) ? (int) $this->get[$name] : $default_value;
}
/**
* Get a form value
*
* @access public
* @param string $name Form field name
* @return string|null
*/
public function getValue($name)
{
$values = $this->getValues();
return isset($values[$name]) ? $values[$name] : null;
}
/**
* Get form values and check for CSRF token
*
* @access public
* @return array
*/
public function getValues()
{
if (! empty($this->post) && ! empty($this->post['csrf_token']) && $this->token->validateCSRFToken($this->post['csrf_token'])) {
unset($this->post['csrf_token']);
return $this->filterValues($this->post);
}
return array();
}
/**
* Get POST values without modification
*
* @return array
*/
public function getRawFormValues()
{
return $this->post;
}
/**
* Get POST value without modification
*
* @param $name
* @return mixed|null
*/
public function getRawValue($name)
{
return isset($this->post[$name]) ? $this->post[$name] : null;
}
/**
* 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
* @param bool $enforceContentType
* @return array
*/
public function getJson($enforceContentType = true)
{
if ($enforceContentType && ! $this->isJsonContentType()) {
return array();
}
return json_decode($this->getBody(), true) ?: array();
}
/**
* Get the content of an uploaded file
*
* @access public
* @param string $name Form file name
* @return string
*/
public function getFileContent($name)
{
if (isset($this->files[$name]['tmp_name'])) {
return file_get_contents($this->files[$name]['tmp_name']);
}
return '';
}
/**
* Get the path of an uploaded file
*
* @access public
* @param string $name Form file name
* @return string
*/
public function getFilePath($name)
{
return isset($this->files[$name]['tmp_name']) ? $this->files[$name]['tmp_name'] : '';
}
/**
* Get info of an uploaded file
*
* @access public
* @param string $name Form file name
* @return array
*/
public function getFileInfo($name)
{
return isset($this->files[$name]) ? $this->files[$name] : array();
}
/**
* Return HTTP method
*
* @access public
* @return bool
*/
public function getMethod()
{
return $this->getServerVariable('REQUEST_METHOD');
}
/**
* Return true if the HTTP request is sent with the POST method
*
* @access public
* @return bool
*/
public function isPost()
{
return $this->getServerVariable('REQUEST_METHOD') === 'POST';
}
/**
* Return true if the HTTP request is an Ajax request
*
* @access public
* @return bool
*/
public function isAjax()
{
return $this->getHeader('X-Requested-With') === 'XMLHttpRequest';
}
/**
* Check if the request Content-Type is JSON
*
* @access public
* @return bool
*/
public function isJsonContentType()
{
$contentType = $this->getServerVariable('CONTENT_TYPE');
if ($contentType === '') {
$contentType = $this->getServerVariable('HTTP_CONTENT_TYPE');
}
return stripos($contentType, 'application/json') === 0;
}
/**
* Check if the page is requested through HTTPS
*
* Note: IIS return the value 'off' and other web servers an empty value when it's not HTTPS
*
* @access public
* @return boolean
*/
public function isHTTPS()
{
if ($this->getServerVariable('HTTP_X_FORWARDED_PROTO') === 'https') {
return true;
}
return $this->getServerVariable('HTTPS') !== '' && $this->server['HTTPS'] !== 'off';
}
/**
* Get cookie value
*
* @access public
* @param string $name
* @return string
*/
public function getCookie($name)
{
return isset($this->cookies[$name]) ? $this->cookies[$name] : '';
}
/**
* Return a HTTP header value
*
* @access public
* @param string $name Header name
* @return string
*/
public function getHeader($name)
{
$name = 'HTTP_'.str_replace('-', '_', strtoupper($name));
return $this->getServerVariable($name);
}
/**
* Get remote user
*
* @access public
* @param array $trustedProxyNetworks
* @return string
*/
public function getRemoteUser(array $trustedProxyNetworks = [])
{
if (! $this->isTrustedProxy($trustedProxyNetworks)) {
return '';
}
return $this->getServerVariable(REVERSE_PROXY_USER_HEADER);
}
/**
* Get remote email
*
* @access public
* @param array $trustedProxyNetworks
* @return string
*/
public function getRemoteEmail(array $trustedProxyNetworks = [])
{
if (! $this->isTrustedProxy($trustedProxyNetworks)) {
return '';
}
return $this->getServerVariable(REVERSE_PROXY_EMAIL_HEADER);
}
/**
* Get remote user full name
*
* @access public
* @param array $trustedProxyNetworks
* @return string
*/
public function getRemoteName(array $trustedProxyNetworks = [])
{
if (! $this->isTrustedProxy($trustedProxyNetworks)) {
return '';
}
return $this->getServerVariable(REVERSE_PROXY_FULLNAME_HEADER);
}
/**
* Returns query string
*
* @access public
* @return string
*/
public function getQueryString()
{
return $this->getServerVariable('QUERY_STRING');
}
/**
* Return URI
*
* @access public
* @return string
*/
public function getUri()
{
return $this->getServerVariable('REQUEST_URI');
}
/**
* Check if a redirect URI is safe (relative path)
*
* @access public
* @param string $uri Redirect URI
* @return bool
*/
public function isSafeRedirectUri($uri)
{
$uri = trim($uri);
if ($uri === '') {
return false;
}
// Reject backslashes
if (str_contains($uri, '\\')) {
return false;
}
// Reject if it starts with // (protocol-relative)
if (str_starts_with($uri, '//')) {
return false;
}
// Reject if it does not start with a slash (relative path)
if (! str_starts_with($uri, '/')) {
return false;
}
$parsedUrl = parse_url($uri);
if ($parsedUrl === false) {
return false;
}
// Reject if it contains a scheme or host (partial or full URL)
if (isset($parsedUrl['scheme']) || isset($parsedUrl['host'])) {
return false;
}
return true;
}
/**
* Get the user agent
*
* @access public
* @return string
*/
public function getUserAgent()
{
return empty($this->server['HTTP_USER_AGENT']) ? t('Unknown') : $this->server['HTTP_USER_AGENT'];
}
/**
* Get the client IP address
*
* It returns the proxy IP address if the request is sent through a reverse proxy or the direct client IP address otherwise.
*
* @access public
* @return string
*/
public function getClientIpAddress()
{
return $this->getServerVariable('REMOTE_ADDR');
}
/**
* Get the real user IP address considering trusted proxy headers and networks
*
* @access public
* @param array $trustedProxyHeaders List of trusted proxy headers (default: TRUSTED_PROXY_HEADERS constant)
* @param array $trustedProxyNetworks List of trusted proxy networks (default: TRUSTED_PROXY_NETWORKS constant)
* @return string
*/
public function getIpAddress(array $trustedProxyHeaders = [], array $trustedProxyNetworks = [])
{
$trustedProxyHeaders = array_filter(array_map('trim', $trustedProxyHeaders ?: explode(',', TRUSTED_PROXY_HEADERS)));
$useProxyHeaders = ! empty($trustedProxyHeaders) && $this->isTrustedProxy($trustedProxyNetworks);
$keys = $useProxyHeaders ? $trustedProxyHeaders : [];
foreach ($keys as $key) {
if ($this->getServerVariable($key) !== '') {
foreach (explode(',', $this->server[$key]) as $ipAddress) {
$ipAddress = trim($ipAddress);
if (filter_var($ipAddress, FILTER_VALIDATE_IP)) {
return $ipAddress;
}
}
}
}
return $this->getClientIpAddress();
}
/**
* Get start time
*
* @access public
* @return float
*/
public function getStartTime()
{
return $this->getServerVariable('REQUEST_TIME_FLOAT') ?: 0;
}
/**
* Get server variable
*
* @access public
* @param string $variable
* @return string
*/
public function getServerVariable($variable)
{
return isset($this->server[$variable]) ? $this->server[$variable] : '';
}
protected function filterValues(array $values)
{
foreach ($values as $key => $value) {
// IE11 Workaround when submitting multipart/form-data
if (strpos($key, '-----------------------------') === 0) {
unset($values[$key]);
}
}
return $values;
}
/**
* Check if an IP address belongs to a trusted proxy network
*
* @access public
* @param array $trustedProxyNetworks
* @return bool
*/
public function isTrustedProxy(array $trustedProxyNetworks = [])
{
$ipAddress = $this->getClientIpAddress();
if ($ipAddress === '') {
return false;
}
$trustedProxyNetworks = array_filter(array_map('trim', $trustedProxyNetworks ?: explode(',', TRUSTED_PROXY_NETWORKS)));
if (empty($trustedProxyNetworks)) {
return false;
}
$this->logger->debug('Checking if IP address {ip} belongs to trusted proxy networks: {networks}', ['ip' => $ipAddress, 'networks' => implode(', ', $trustedProxyNetworks)]);
return $this->isIpInNetworks($ipAddress, $trustedProxyNetworks);
}
/**
* Check if an IP belongs to any of the provided networks
*
* @access protected
* @param string $ipAddress
* @param array $networks
* @return bool
*/
protected function isIpInNetworks($ipAddress, array $networks)
{
if (! filter_var($ipAddress, FILTER_VALIDATE_IP)) {
return false;
}
$ipBinary = inet_pton($ipAddress);
foreach ($networks as $network) {
if ($network === '') {
continue;
}
$mask = null;
if (strpos($network, '/') !== false) {
list($networkAddress, $mask) = explode('/', $network, 2);
} else {
$networkAddress = $network;
}
if (! filter_var($networkAddress, FILTER_VALIDATE_IP)) {
continue;
}
$networkBinary = inet_pton($networkAddress);
if ($networkBinary === false || strlen($networkBinary) !== strlen($ipBinary)) {
continue;
}
$maxMask = strlen($networkBinary) * 8;
$mask = ($mask === null || $mask === '') ? $maxMask : max(0, min((int) $mask, $maxMask));
if ($this->ipMatchesNetwork($ipBinary, $networkBinary, $mask)) {
return true;
}
}
return false;
}
/**
* Perform a binary comparison between an IP and a network mask
*
* @access protected
* @param string $ipBinary
* @param string $networkBinary
* @param int $mask
* @return bool
*/
protected function ipMatchesNetwork($ipBinary, $networkBinary, $mask)
{
if ($mask === 0) {
return true;
}
$bytes = (int) floor($mask / 8);
$bits = $mask % 8;
if ($bytes > 0 && strncmp($ipBinary, $networkBinary, $bytes) !== 0) {
return false;
}
if ($bits === 0) {
return true;
}
$maskByte = ~((1 << (8 - $bits)) - 1) & 0xFF;
$ipByte = ord($ipBinary[$bytes]);
$networkByte = ord($networkBinary[$bytes]);
return ($ipByte & $maskByte) === ($networkByte & $maskByte);
}
}
+419
View File
@@ -0,0 +1,419 @@
<?php
namespace Kanboard\Core\Http;
use Kanboard\Core\Base;
use Kanboard\Core\Csv;
/**
* Response class
*
* @package http
* @author Frederic Guillot
*/
class Response extends Base
{
private $httpStatusCode = 200;
private $httpHeaders = array();
private $httpBody = '';
private $responseSent = false;
/**
* Return true if the response have been sent to the user agent
*
* @access public
* @return bool
*/
public function isResponseAlreadySent()
{
return $this->responseSent;
}
/**
* Set HTTP status code
*
* @access public
* @param integer $statusCode
* @return $this
*/
public function withStatusCode($statusCode)
{
$this->httpStatusCode = $statusCode;
return $this;
}
/**
* Set HTTP header
*
* @access public
* @param string $header
* @param string $value
* @return $this
*/
public function withHeader($header, $value)
{
$this->httpHeaders[$header] = $value;
return $this;
}
/**
* Set content type header
*
* @access public
* @param string $value
* @return $this
*/
public function withContentType($value)
{
$this->httpHeaders['Content-Type'] = $value;
return $this;
}
/**
* Set default security headers
*
* @access public
* @return $this
*/
public function withSecurityHeaders()
{
$this->httpHeaders['X-Content-Type-Options'] = 'nosniff';
$this->httpHeaders['X-XSS-Protection'] = '1; mode=block';
return $this;
}
/**
* Set header Content-Security-Policy
*
* @access public
* @param array $policies
* @return $this
*/
public function withContentSecurityPolicy(array $policies = array())
{
$values = '';
foreach ($policies as $policy => $acl) {
$values .= $policy.' '.trim($acl).'; ';
}
$this->withHeader('Content-Security-Policy', $values);
return $this;
}
/**
* Set header X-Frame-Options
*
* @access public
* @return $this
*/
public function withXframe()
{
$this->withHeader('X-Frame-Options', 'DENY');
return $this;
}
/**
* Set header Strict-Transport-Security (only if we use HTTPS)
*
* @access public
* @return $this
*/
public function withStrictTransportSecurity()
{
if ($this->request->isHTTPS()) {
$this->withHeader('Strict-Transport-Security', 'max-age=31536000');
}
return $this;
}
/**
* Add P3P headers for Internet Explorer
*
* @access public
* @return $this
*/
public function withP3P()
{
$this->withHeader('P3P', 'CP="IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT"');
return $this;
}
/**
* Set HTTP response body
*
* @access public
* @param string $body
* @return $this
*/
public function withBody($body)
{
$this->httpBody = $body;
return $this;
}
/**
* Send headers to cache a resource
*
* @access public
* @param integer $duration
* @param string $etag
* @return $this
*/
public function withCache($duration, $etag = '')
{
$this
->withHeader('Pragma', 'cache')
->withHeader('Expires', gmdate('D, d M Y H:i:s', time() + $duration) . ' GMT')
->withHeader('Cache-Control', 'public, max-age=' . $duration)
;
if ($etag) {
$this->withHeader('ETag', '"' . $etag . '"');
}
return $this;
}
/**
* Send no cache headers
*
* @access public
* @return $this
*/
public function withoutCache()
{
$this->withHeader('Pragma', 'no-cache');
$this->withHeader('Expires', 'Sat, 26 Jul 1997 05:00:00 GMT');
return $this;
}
/**
* Force the browser to download an attachment
*
* @access public
* @param string $filename
* @return $this
*/
public function withFileDownload($filename)
{
$this->withHeader('Content-Disposition', 'attachment; filename="'.$filename.'"');
$this->withHeader('Content-Transfer-Encoding', 'binary');
$this->withHeader('Content-Type', 'application/octet-stream');
return $this;
}
/**
* Send headers and body
*
* @access public
*/
public function send()
{
$this->responseSent = true;
if ($this->httpStatusCode !== 200) {
header('Status: '.$this->httpStatusCode);
header($this->request->getServerVariable('SERVER_PROTOCOL').' '.$this->httpStatusCode);
}
foreach ($this->httpHeaders as $header => $value) {
header($header.': '.$value);
}
if (! empty($this->httpBody)) {
echo $this->httpBody;
}
}
/**
* Send a custom HTTP status code
*
* @access public
* @param integer $statusCode
*/
public function status($statusCode)
{
$this->withStatusCode($statusCode);
$this->send();
}
/**
* Redirect to another URL
*
* @access public
* @param string $url Redirection URL
* @param boolean $self If Ajax request and true: refresh the current page
*/
public function redirect($url, $self = false)
{
if ($this->request->isAjax()) {
$this->withHeader('X-Ajax-Redirect', $self ? 'self' : $url);
} else {
$this->withHeader('Location', $url);
}
$this->send();
}
/**
* Send a HTML response
*
* @access public
* @param string $data
* @param integer $statusCode
*/
public function html($data, $statusCode = 200)
{
$this->withStatusCode($statusCode);
$this->withContentType('text/html; charset=utf-8');
$this->withBody($data);
$this->send();
}
/**
* Send a text response
*
* @access public
* @param string $data
* @param integer $statusCode
*/
public function text($data, $statusCode = 200)
{
$this->withStatusCode($statusCode);
$this->withContentType('text/plain; charset=utf-8');
$this->withBody($data);
$this->send();
}
/**
* Send a CSV response
*
* @access public
* @param array $data Data to serialize in csv
* @param bool $addBOM Add BOM header
*/
public function csv(array $data, $addBOM = false)
{
$this->withoutCache();
$this->withContentType('text/csv; charset=utf-8');
$this->send();
Csv::output($data, $addBOM);
}
/**
* Send a Json response
*
* @access public
* @param array $data Data to serialize in json
* @param integer $statusCode HTTP status code
*/
public function json(array $data, $statusCode = 200)
{
$this->withStatusCode($statusCode);
$this->withContentType('application/json');
$this->withoutCache();
$this->withBody(json_encode($data));
$this->send();
}
/**
* Send a XML response
*
* @access public
* @param string $data
* @param integer $statusCode
*/
public function xml($data, $statusCode = 200)
{
$this->withStatusCode($statusCode);
$this->withContentType('text/xml; charset=utf-8');
$this->withoutCache();
$this->withBody($data);
$this->send();
}
/**
* Send a javascript response
*
* @access public
* @param string $data
* @param integer $statusCode
*/
public function js($data, $statusCode = 200)
{
$this->withStatusCode($statusCode);
$this->withContentType('text/javascript; charset=utf-8');
$this->withBody($data);
$this->send();
}
/**
* Send a css response
*
* @access public
* @param string $data
* @param integer $statusCode
*/
public function css($data, $statusCode = 200)
{
$this->withStatusCode($statusCode);
$this->withContentType('text/css; charset=utf-8');
$this->withBody($data);
$this->send();
}
/**
* Send a binary response
*
* @access public
* @param string $data
* @param integer $statusCode
*/
public function binary($data, $statusCode = 200)
{
$this->withStatusCode($statusCode);
$this->withoutCache();
$this->withHeader('Content-Transfer-Encoding', 'binary');
$this->withContentType('application/octet-stream');
$this->withBody($data);
$this->send();
}
/**
* Send a iCal response
*
* @access public
* @param string $data
* @param integer $statusCode
*/
public function ical($data, $statusCode = 200)
{
$this->withStatusCode($statusCode);
$this->withContentType('text/calendar; charset=utf-8');
$this->withBody($data);
$this->send();
}
/**
* Send a PDF response
*
* @access public
* @param string $data
* @param integer $statusCode
* @param string $fileName
*/
public function pdf($data, int $statusCode = 200, string $fileName = "")
{
$this->withStatusCode($statusCode);
$this->withContentType('application/pdf');
if (!empty($fileName)) {
$this->httpHeaders["content-disposition"] = "attachment; filename=".$fileName;
}
$this->withBody($data);
$this->send();
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
namespace Kanboard\Core\Http;
use Kanboard\Core\Base;
/**
* Route Handler
*
* @package http
* @author Frederic Guillot
*/
class Route extends Base
{
/**
* Flag that enable the routing table
*
* @access private
* @var boolean
*/
private $activated = false;
/**
* Store routes for path lookup
*
* @access private
* @var array
*/
private $paths = array();
/**
* Store routes for url lookup
*
* @access private
* @var array
*/
private $urls = array();
/**
* Enable routing table
*
* @access public
* @return Route
*/
public function enable()
{
$this->activated = true;
return $this;
}
/**
* Add route
*
* @access public
* @param string $path
* @param string $controller
* @param string $action
* @param string $plugin
* @return Route
*/
public function addRoute($path, $controller, $action, $plugin = '')
{
if ($this->activated) {
$path = ltrim($path, '/');
$items = explode('/', $path);
$params = $this->findParams($items);
$this->paths[] = array(
'items' => $items,
'count' => count($items),
'controller' => $controller,
'action' => $action,
'plugin' => $plugin,
);
$this->urls[$plugin][$controller][$action][] = array(
'path' => $path,
'params' => $params,
'count' => count($params),
);
}
return $this;
}
/**
* Find a route according to the given path
*
* @access public
* @param string $path
* @return array
*/
public function findRoute($path)
{
$items = explode('/', ltrim($path, '/'));
$count = count($items);
foreach ($this->paths as $route) {
if ($count === $route['count']) {
$params = array();
for ($i = 0; $i < $count; $i++) {
if ($route['items'][$i][0] === ':') {
$params[substr($route['items'][$i], 1)] = urldecode($items[$i]);
} elseif ($route['items'][$i] !== $items[$i]) {
break;
}
}
if ($i === $count) {
$this->request->setParams($params);
return array(
'controller' => $route['controller'],
'action' => $route['action'],
'plugin' => $route['plugin'],
);
}
}
}
return array(
'controller' => 'DashboardController',
'action' => 'show',
'plugin' => '',
);
}
/**
* Find route url
*
* @access public
* @param string $controller
* @param string $action
* @param array $params
* @param string $plugin
* @return string
*/
public function findUrl($controller, $action, array $params = array(), $plugin = '')
{
if ($plugin === '' && isset($params['plugin'])) {
$plugin = $params['plugin'];
unset($params['plugin']);
}
if (! isset($this->urls[$plugin][$controller][$action])) {
return '';
}
foreach ($this->urls[$plugin][$controller][$action] as $route) {
if (array_diff_key($params, $route['params']) === array()) {
$url = $route['path'];
$i = 0;
foreach ($params as $variable => $value) {
$value = urlencode($value);
$url = str_replace(':'.$variable, $value, $url);
$i++;
}
if ($i === $route['count']) {
return $url;
}
}
}
return '';
}
/**
* Find url params
*
* @access public
* @param array $items
* @return array
*/
public function findParams(array $items)
{
$params = array();
foreach ($items as $item) {
if ($item !== '' && $item[0] === ':') {
$params[substr($item, 1)] = true;
}
}
return $params;
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace Kanboard\Core\Http;
use Kanboard\Core\Base;
/**
* Route Dispatcher
*
* @package http
* @author Frederic Guillot
*/
class Router extends Base
{
const DEFAULT_CONTROLLER = 'DashboardController';
const DEFAULT_METHOD = 'show';
/**
* Plugin name
*
* @access private
* @var string
*/
private $currentPluginName = '';
/**
* Controller
*
* @access private
* @var string
*/
private $currentControllerName = '';
/**
* Action
*
* @access private
* @var string
*/
private $currentActionName = '';
/**
* Get plugin name
*
* @access public
* @return string
*/
public function getPlugin()
{
return $this->currentPluginName;
}
/**
* Get controller
*
* @access public
* @return string
*/
public function getController()
{
return $this->currentControllerName;
}
/**
* Get action
*
* @access public
* @return string
*/
public function getAction()
{
return $this->currentActionName;
}
/**
* Get the path to compare patterns
*
* @access public
* @return string
*/
public function getPath()
{
$path = substr($this->request->getUri(), strlen($this->helper->url->dir()));
if ($this->request->getQueryString() !== '') {
$path = substr($path, 0, - strlen($this->request->getQueryString()) - 1);
}
if ($path !== '' && $path[0] === '/') {
$path = substr($path, 1);
}
return $path;
}
/**
* Find controller/action from the route table or from get arguments
*
* @access public
*/
public function dispatch()
{
$controller = $this->request->getStringParam('controller');
$action = $this->request->getStringParam('action');
$plugin = $this->request->getStringParam('plugin');
if ($controller === '') {
$route = $this->route->findRoute($this->getPath());
$controller = $route['controller'];
$action = $route['action'];
$plugin = $route['plugin'];
}
$this->currentControllerName = ucfirst($this->sanitize($controller, self::DEFAULT_CONTROLLER));
$this->currentActionName = $this->sanitize($action, self::DEFAULT_METHOD);
$this->currentPluginName = ucfirst($this->sanitize($plugin));
}
/**
* Check controller and action parameter
*
* @access public
* @param string $value
* @param string $default
* @return string
*/
public function sanitize($value, $default = '')
{
return preg_match('/^[a-zA-Z_0-9]+$/', $value) ? $value : $default;
}
}
+247
View File
@@ -0,0 +1,247 @@
<?php
namespace Kanboard\Core\Ldap;
use LogicException;
use Psr\Log\LoggerInterface;
/**
* LDAP Client
*
* @package ldap
* @author Frederic Guillot
*/
class Client
{
/**
* LDAP resource
*
* @access protected
* @var resource
*/
protected $ldap;
/**
* Logger instance
*
* @access private
* @var LoggerInterface
*/
private $logger;
/**
* Establish LDAP connection
*
* @static
* @access public
* @param string $username
* @param string $password
* @return Client
*/
public static function connect($username = null, $password = null)
{
$client = new static;
$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 URI (ldap[s]://hostname:port) or hostname (deprecated)
* @param int $port LDAP port (deprecated)
* @param bool $tls Start TLS
* @param bool $verify Skip SSL certificate verification
* @return Client
* @throws ClientException
* @throws ConnectionException
*/
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');
}
if (! $verify) {
putenv('LDAPTLS_REQCERT=never');
}
if (filter_var($server, FILTER_VALIDATE_URL) !== false) {
$this->ldap = @ldap_connect($server);
} else {
$this->ldap = @ldap_connect($server, $port);
}
if ($this->ldap === false) {
throw new ConnectionException('Malformed LDAP server hostname or LDAP server port');
}
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($this->ldap)) {
throw new ConnectionException('Unable to start LDAP TLS (' . $this->getLdapError() . ')');
}
return $this;
}
/**
* Anonymous authentication
*
* @access public
* @throws ClientException
* @return boolean
*/
public function useAnonymousAuthentication()
{
if (! @ldap_bind($this->ldap)) {
$this->checkForServerConnectionError();
throw new ClientException('Unable to perform anonymous binding => '.$this->getLdapError());
}
return true;
}
/**
* Authentication with username/password
*
* @access public
* @throws ClientException
* @param string $bind_rdn
* @param string $bind_password
* @return boolean
*/
public function authenticate($bind_rdn, $bind_password)
{
if (! @ldap_bind($this->ldap, $bind_rdn, $bind_password)) {
$this->checkForServerConnectionError();
throw new ClientException('LDAP authentication failure for "'.$bind_rdn.'" => '.$this->getLdapError());
}
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;
}
/**
* Set logger
*
* @access public
* @param LoggerInterface $logger
* @return Client
*/
public function setLogger(LoggerInterface $logger)
{
$this->logger = $logger;
return $this;
}
/**
* Get logger
*
* @access public
* @return LoggerInterface
*/
public function getLogger()
{
return $this->logger;
}
/**
* Test if a logger is defined
*
* @access public
* @return boolean
*/
public function hasLogger()
{
return $this->logger !== null;
}
/**
* Raise ConnectionException if the application is not able to connect to LDAP server
*
* @access protected
* @throws ConnectionException
*/
protected function checkForServerConnectionError()
{
if (ldap_errno($this->ldap) === -1) {
throw new ConnectionException('Unable to connect to LDAP server (' . $this->getLdapError() . ')');
}
}
/**
* Get extended LDAP error message
*
* @return string
*/
protected function getLdapError()
{
ldap_get_option($this->ldap, LDAP_OPT_ERROR_STRING, $extendedErrorMessage);
$errorMessage = ldap_error($this->ldap);
$errorCode = ldap_errno($this->ldap);
return 'Code="'.$errorCode.'"; Error="'.$errorMessage.'"; ExtendedError="'.$extendedErrorMessage.'"';
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Kanboard\Core\Ldap;
use Exception;
/**
* LDAP Client Exception
*
* @package ldap
* @author Frederic Guillot
*/
class ClientException extends Exception
{
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Kanboard\Core\Ldap;
use Exception;
/**
* LDAP Connection Exception
*
* @package ldap
* @author Frederic Guillot
*/
class ConnectionException extends Exception
{
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Kanboard\Core\Ldap;
/**
* LDAP Entries
*
* @package ldap
* @author Frederic Guillot
*/
class Entries
{
/**
* LDAP entries
*
* @access protected
* @var array
*/
protected $entries = array();
/**
* Constructor
*
* @access public
* @param array $entries
*/
public function __construct(array $entries)
{
$this->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());
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace Kanboard\Core\Ldap;
/**
* LDAP Entry
*
* @package ldap
* @author Frederic Guillot
*/
class Entry
{
/**
* LDAP entry
*
* @access protected
* @var array
*/
protected $entry = array();
/**
* Constructor
*
* @access public
* @param array $entry
*/
public function __construct(array $entry)
{
$this->entry = $entry;
}
/**
* Get all attribute values
*
* @access public
* @param string $attribute
* @return string[]
*/
public function getAll($attribute)
{
$attributes = array();
if ($attribute === null) {
return $attributes;
}
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 = '')
{
if ($attribute === null) {
return $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);
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace Kanboard\Core\Ldap;
use LogicException;
use Kanboard\Group\LdapGroupProvider;
/**
* LDAP Group Finder
*
* @package ldap
* @author Frederic Guillot
*/
class Group
{
/**
* Query
*
* @access protected
* @var Query
*/
protected $query;
/**
* Constructor
*
* @access public
* @param Query $query
*/
public function __construct(Query $query)
{
$this->query = $query;
}
/**
* Get groups
*
* @static
* @access public
* @param Client $client
* @param string $query
* @return LdapGroupProvider[]
*/
public static function getGroups(Client $client, $query)
{
$self = new static(new Query($client));
return $self->find($query);
}
/**
* Find groups
*
* @access public
* @param string $query
* @return array
*/
public function find($query)
{
$this->query->execute($this->getBaseDn(), $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 strtolower(LDAP_GROUP_ATTRIBUTE_NAME);
}
/**
* Get LDAP group base DN
*
* @access public
* @return string
*/
public function getBaseDn()
{
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;
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Kanboard\Core\Ldap;
/**
* LDAP Query
*
* @package ldap
* @author Frederic Guillot
*/
class Query
{
/**
* LDAP client
*
* @access protected
* @var Client
*/
protected $client = null;
/**
* Query result
*
* @access protected
* @var array
*/
protected $entries = array();
/**
* Constructor
*
* @access public
* @param Client $client
*/
public function __construct(Client $client)
{
$this->client = $client;
}
/**
* Execute query
*
* @access public
* @param string $baseDn
* @param string $filter
* @param array $attributes
* @param integer $limit
* @return $this
*/
public function execute($baseDn, $filter, array $attributes, $limit = 0)
{
if (DEBUG && $this->client->hasLogger()) {
$this->client->getLogger()->debug('BaseDN='.$baseDn);
$this->client->getLogger()->debug('Filter='.$filter);
$this->client->getLogger()->debug('Attributes='.implode(', ', $attributes));
}
$sr = @ldap_search($this->client->getConnection(), $baseDn, $filter, $attributes, null, $limit);
if ($sr === false) {
return $this;
}
$entries = ldap_get_entries($this->client->getConnection(), $sr);
if ($entries === false || count($entries) === 0 || $entries['count'] == 0) {
return $this;
}
$this->entries = $entries;
if (DEBUG && $this->client->hasLogger()) {
$this->client->getLogger()->debug('NbEntries='.$entries['count']);
}
return $this;
}
/**
* Return true if the query returned a result
*
* @access public
* @return boolean
*/
public function hasResult()
{
return ! empty($this->entries);
}
/**
* Get LDAP Entries
*
* @access public
* @return Entries
*/
public function getEntries()
{
return new Entries($this->entries);
}
}
+366
View File
@@ -0,0 +1,366 @@
<?php
namespace Kanboard\Core\Ldap;
use LogicException;
use Kanboard\Core\Security\Role;
use Kanboard\User\LdapUserProvider;
/**
* LDAP User Finder
*
* @package ldap
* @author Frederic Guillot
*/
class User
{
/**
* Query
*
* @access protected
* @var Query
*/
protected $query;
/**
* LDAP Group object
*
* @access protected
* @var Group
*/
protected $group;
/**
* Constructor
*
* @access public
* @param Query $query
* @param Group $group
*/
public function __construct(Query $query, ?Group $group = null)
{
$this->query = $query;
$this->group = $group;
}
/**
* Get user profile
*
* @static
* @access public
* @param Client $client
* @param string $username
* @return LdapUserProvider
*/
public static function getUser(Client $client, $username)
{
$self = new static(new Query($client), new Group(new Query($client)));
return $self->find($self->getLdapUserPattern($username));
}
/**
* Find user
*
* @access public
* @param string $query
* @return LdapUserProvider
*/
public function find($query)
{
$this->query->execute($this->getBaseDn(), $query, $this->getAttributes());
$user = null;
if ($this->query->hasResult()) {
$user = $this->build();
}
return $user;
}
/**
* Get user groupIds (DN)
*
* 1) If configured, use memberUid and posixGroup
* 2) Otherwise, use memberOf
*
* @access protected
* @param Entry $entry
* @return string[]
*/
protected function getGroups(Entry $entry)
{
$userattr = '';
if ('username' == $this->getGroupUserAttribute()) {
$userattr = $entry->getFirstValue($this->getAttributeUsername());
} elseif ('dn' == $this->getGroupUserAttribute()) {
$userattr = $entry->getDn();
}
$groupIds = array();
if (! empty($userattr) && $this->group !== null && $this->hasGroupUserFilter()) {
$escapedUserAttribute = ldap_escape($userattr, '', LDAP_ESCAPE_FILTER);
$groups = $this->group->find(sprintf($this->getGroupUserFilter(), $escapedUserAttribute));
foreach ($groups as $group) {
$groupIds[] = $group->getExternalId();
}
} else {
$groupIds = $entry->getAll($this->getAttributeGroup());
}
return $groupIds;
}
/**
* Get role from LDAP groups
*
* Note: Do not touch the current role if groups are not configured
*
* @access protected
* @param string[] $groupIds
* @return string
*/
protected function getRole(array $groupIds)
{
if (! $this->hasGroupsConfigured()) {
return null;
}
if (LDAP_USER_DEFAULT_ROLE_MANAGER) {
$role = Role::APP_MANAGER;
} else {
$role = Role::APP_USER;
}
foreach ($groupIds as $groupId) {
$groupId = strtolower($groupId);
if ($groupId === strtolower($this->getGroupAdminDn())) {
$role = Role::APP_ADMIN;
break;
}
if ($groupId === strtolower($this->getGroupManagerDn())) {
$role = Role::APP_MANAGER;
}
}
return $role;
}
/**
* Build user profile
*
* @access protected
* @return LdapUserProvider
*/
protected function build()
{
$entry = $this->query->getEntries()->getFirstEntry();
$groupIds = $this->getGroups($entry);
return new LdapUserProvider(
$entry->getDn(),
$entry->getFirstValue($this->getAttributeUsername()),
$entry->getFirstValue($this->getAttributeName()),
$entry->getFirstValue($this->getAttributeEmail()),
$this->getRole($groupIds),
$groupIds,
$entry->getFirstValue($this->getAttributePhoto()),
$entry->getFirstValue($this->getAttributeLanguage())
);
}
/**
* 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(),
$this->getAttributePhoto(),
$this->getAttributeLanguage(),
)));
}
/**
* Get LDAP account id attribute
*
* @access public
* @return string
*/
public function getAttributeUsername()
{
if (! LDAP_USER_ATTRIBUTE_USERNAME) {
throw new LogicException('LDAP username attribute empty, check the parameter LDAP_USER_ATTRIBUTE_USERNAME');
}
return strtolower(LDAP_USER_ATTRIBUTE_USERNAME);
}
/**
* Get LDAP user name attribute
*
* @access public
* @return string
*/
public function getAttributeName()
{
if (! LDAP_USER_ATTRIBUTE_FULLNAME) {
throw new LogicException('LDAP full name attribute empty, check the parameter LDAP_USER_ATTRIBUTE_FULLNAME');
}
return strtolower(LDAP_USER_ATTRIBUTE_FULLNAME);
}
/**
* Get LDAP account email attribute
*
* @access public
* @return string
*/
public function getAttributeEmail()
{
if (! LDAP_USER_ATTRIBUTE_EMAIL) {
throw new LogicException('LDAP email attribute empty, check the parameter LDAP_USER_ATTRIBUTE_EMAIL');
}
return strtolower(LDAP_USER_ATTRIBUTE_EMAIL);
}
/**
* Get LDAP account memberOf attribute
*
* @access public
* @return string
*/
public function getAttributeGroup()
{
return strtolower(LDAP_USER_ATTRIBUTE_GROUPS);
}
/**
* Get LDAP profile photo attribute
*
* @access public
* @return string
*/
public function getAttributePhoto()
{
return strtolower(LDAP_USER_ATTRIBUTE_PHOTO);
}
/**
* Get LDAP language attribute
*
* @access public
* @return string
*/
public function getAttributeLanguage()
{
return strtolower(LDAP_USER_ATTRIBUTE_LANGUAGE);
}
/**
* Get LDAP Group User filter
*
* @access public
* @return string
*/
public function getGroupUserFilter()
{
return LDAP_GROUP_USER_FILTER;
}
/**
* Get LDAP Group User attribute
*
* @access public
* @return string
*/
public function getGroupUserAttribute()
{
return LDAP_GROUP_USER_ATTRIBUTE;
}
/**
* Return true if LDAP Group User filter is defined
*
* @access public
* @return string
*/
public function hasGroupUserFilter()
{
return $this->getGroupUserFilter() !== '' && $this->getGroupUserFilter() !== null;
}
/**
* Return true if LDAP Group mapping are configured
*
* @access public
* @return boolean
*/
public function hasGroupsConfigured()
{
return $this->getGroupAdminDn() || $this->getGroupManagerDn();
}
/**
* Get LDAP admin group DN
*
* @access public
* @return string
*/
public function getGroupAdminDn(): string
{
return strtolower(LDAP_GROUP_ADMIN_DN);
}
/**
* Get LDAP application manager group DN
*
* @access public
* @return string
*/
public function getGroupManagerDn(): string
{
return LDAP_GROUP_MANAGER_DN;
}
/**
* Get LDAP user base DN
*
* @access public
* @return string
*/
public function getBaseDn()
{
return LDAP_USER_BASE_DN;
}
/**
* Get LDAP user pattern
*
* @access public
* @param string $username
* @param string $filter
* @return string
*/
public function getLdapUserPattern($username, $filter = LDAP_USER_FILTER)
{
if (! $filter) {
throw new LogicException('LDAP user filter is not configured. Please set the LDAP_USER_FILTER parameter in your configuration file');
}
$escapedUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
return str_replace('%s', $escapedUsername, $filter);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace Kanboard\Core\Log;
use Psr\Log\AbstractLogger;
use Psr\Log\LogLevel;
/**
* Base class for loggers
*
* @package Kanboard\Core\Log
* @author Frédéric Guillot
*/
abstract class Base extends AbstractLogger
{
/**
* Minimum log level for the logger
*
* @access private
* @var string
*/
private $level = LogLevel::DEBUG;
/**
* Set minimum log level
*
* @access public
* @param string $level
*/
public function setLevel($level)
{
$this->level = $level;
}
/**
* Get minimum log level
*
* @access public
* @return string
*/
public function getLevel()
{
return $this->level;
}
/**
* Dump to log a variable (by example an array)
*
* @param mixed $variable
*/
public function dump($variable)
{
$this->log(LogLevel::DEBUG, var_export($variable, true));
}
/**
* Interpolates context values into the message placeholders.
*
* @access protected
* @param string $message
* @param array $context
* @return string
*/
protected function interpolate($message, array $context = array())
{
// build a replacement array with braces around the context keys
$replace = array();
foreach ($context as $key => $val) {
$replace['{' . $key . '}'] = $val;
}
// interpolate replacement values into the message and return
return strtr($message, $replace);
}
/**
* Format log message
*
* @param mixed $level
* @param string $message
* @param array $context
* @return string
*/
protected function formatMessage($level, $message, array $context = array())
{
return '['.date('Y-m-d H:i:s').'] ['.$level.'] '.$this->interpolate($message, $context).PHP_EOL;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Kanboard\Core\Log;
use RuntimeException;
/**
* File Logger
*
* @package Kanboard\Core\Log
* @author Frédéric Guillot
*/
class File extends Base
{
/**
* Filename
*
* @access protected
* @var string
*/
protected $filename = '';
/**
* Setup logger configuration
*
* @param string $filename Output file
*/
public function __construct($filename)
{
$this->filename = $filename;
}
/**
* Logs with an arbitrary level.
*
* @param mixed $level
* @param string $message
* @param array $context
*/
public function log($level, $message, array $context = array())
{
$line = $this->formatMessage($level, $message, $context);
if (file_put_contents($this->filename, $line, FILE_APPEND | LOCK_EX) === false) {
throw new RuntimeException('Unable to write to the log file.');
}
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace Kanboard\Core\Log;
use Psr\Log\AbstractLogger;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* Handler for multiple loggers
*
* @package Kanboard\Core\Log
* @author Frédéric Guillot
*/
class Logger extends AbstractLogger implements LoggerAwareInterface
{
/**
* Logger instances
*
* @access private
*/
private $loggers = array();
/**
* Get level priority
*
* @param mixed $level
* @return integer
*/
public function getLevelPriority($level)
{
switch ($level) {
case LogLevel::EMERGENCY:
return 600;
case LogLevel::ALERT:
return 550;
case LogLevel::CRITICAL:
return 500;
case LogLevel::ERROR:
return 400;
case LogLevel::WARNING:
return 300;
case LogLevel::NOTICE:
return 250;
case LogLevel::INFO:
return 200;
}
return 100;
}
/**
* Sets a logger instance on the object
*
* @param LoggerInterface $logger
* @return null
*/
public function setLogger(LoggerInterface $logger)
{
$this->loggers[] = $logger;
}
/**
* Proxy method to the real loggers
*
* @param mixed $level
* @param string $message
* @param array $context
* @return null
*/
public function log($level, $message, array $context = array())
{
foreach ($this->loggers as $logger) {
if ($this->getLevelPriority($level) >= $this->getLevelPriority($logger->getLevel())) {
$logger->log($level, $message, $context);
}
}
}
/**
* Dump variables for debugging
*
* @param mixed $variable
*/
public function dump($variable)
{
foreach ($this->loggers as $logger) {
if ($this->getLevelPriority(LogLevel::DEBUG) >= $this->getLevelPriority($logger->getLevel())) {
$logger->dump($variable);
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Kanboard\Core\Log;
/**
* Stderr logger
*
* @package Kanboard\Core\Log
* @author Frédéric Guillot
*/
class Stderr extends Base
{
/**
* Logs with an arbitrary level.
*
* @param mixed $level
* @param string $message
* @param array $context
* @return null
*/
public function log($level, $message, array $context = array())
{
file_put_contents('php://stderr', $this->formatMessage($level, $message, $context), FILE_APPEND);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Kanboard\Core\Log;
/**
* Stdout logger
*
* @package Kanboard\Core\Log
* @author Frédéric Guillot
*/
class Stdout extends Base
{
/**
* Logs with an arbitrary level.
*
* @param mixed $level
* @param string $message
* @param array $context
* @return null
*/
public function log($level, $message, array $context = array())
{
file_put_contents('php://stdout', $this->formatMessage($level, $message, $context), FILE_APPEND);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace Kanboard\Core\Log;
use RuntimeException;
use Psr\Log\LogLevel;
/**
* Syslog Logger
*
* @package Kanboard\Core\Log
* @author Frédéric Guillot
*/
class Syslog extends Base
{
/**
* Setup Syslog configuration
*
* @param string $ident Application name
* @param int $facility See http://php.net/manual/en/function.openlog.php
*/
public function __construct($ident = 'PHP', $facility = LOG_USER)
{
if (! openlog($ident, LOG_ODELAY | LOG_PID, $facility)) {
throw new RuntimeException('Unable to connect to syslog.');
}
}
/**
* Get syslog priority according to Psr\LogLevel
*
* @param mixed $level
* @return integer
*/
public function getSyslogPriority($level)
{
switch ($level) {
case LogLevel::EMERGENCY:
return LOG_EMERG;
case LogLevel::ALERT:
return LOG_ALERT;
case LogLevel::CRITICAL:
return LOG_CRIT;
case LogLevel::ERROR:
return LOG_ERR;
case LogLevel::WARNING:
return LOG_WARNING;
case LogLevel::NOTICE:
return LOG_NOTICE;
case LogLevel::INFO:
return LOG_INFO;
}
return LOG_DEBUG;
}
/**
* Logs with an arbitrary level.
*
* @param mixed $level
* @param string $message
* @param array $context
* @return null
*/
public function log($level, $message, array $context = array())
{
$syslogPriority = $this->getSyslogPriority($level);
$syslogMessage = $this->interpolate($message, $context);
syslog($syslogPriority, $syslogMessage);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Kanboard\Core\Log;
/**
* Built-in PHP Logger
*
* @package Kanboard\Core\Log
* @author Frédéric Guillot
*/
class System extends Base
{
/**
* Logs with an arbitrary level.
*
* @param mixed $level
* @param string $message
* @param array $context
* @return null
*/
public function log($level, $message, array $context = [])
{
error_log('['.$level.'] '.$this->interpolate($message, $context));
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace Kanboard\Core\Mail;
use Kanboard\Job\EmailJob;
use Pimple\Container;
use Kanboard\Core\Base;
/**
* Mail Client
*
* @package Kanboard\Core\Mail
* @author Frederic Guillot
*/
class Client extends Base
{
/**
* Mail transport instances
*
* @access private
* @var \Pimple\Container
*/
private $transports;
/**
* Constructor
*
* @access public
* @param \Pimple\Container $container
*/
public function __construct(Container $container)
{
parent::__construct($container);
$this->transports = new Container;
}
/**
* Send a HTML email
*
* @access public
* @param string $recipientEmail
* @param string $recipientName
* @param string $subject
* @param string $html
* @return Client
*/
public function send($recipientEmail, $recipientName, $subject, $html, $authorName = null, $authorEmail = null)
{
if (! empty($recipientEmail)) {
$this->queueManager->push(EmailJob::getInstance($this->container)->withParams(
$recipientEmail,
$recipientName,
$subject,
$html,
is_null($authorName) ? $this->getAuthorName() : $authorName,
is_null($authorEmail) ? $this->getAuthorEmail() : $authorEmail
));
}
return $this;
}
/**
* Get author name
*
* @access public
* @return string
*/
public function getAuthorName()
{
$author = 'Kanboard';
if ($this->userSession->isLogged()) {
$author = e('%s via Kanboard', $this->helper->user->getFullname());
}
return $author;
}
/**
* Get author email
*
* @access public
* @return string
*/
public function getAuthorEmail()
{
if ($this->userSession->isLogged()) {
$userData = $this->userSession->getAll();
return ! empty($userData['email']) ? $userData['email'] : '';
}
return '';
}
/**
* Get mail transport instance
*
* @access public
* @param string $transport
* @return ClientInterface
*/
public function getTransport($transport)
{
return $this->transports[$transport];
}
/**
* Add a new mail transport
*
* @access public
* @param string $transport
* @param string $class
* @return Client
*/
public function setTransport($transport, $class)
{
$container = $this->container;
$this->transports[$transport] = function () use ($class, $container) {
return new $class($container);
};
return $this;
}
/**
* Return the list of registered transports
*
* @access public
* @return array
*/
public function getAvailableTransports()
{
$availableTransports = $this->transports->keys();
return array_combine($availableTransports, $availableTransports);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Kanboard\Core\Mail;
/**
* Mail Client Interface
*
* @package Kanboard\Core\Mail
* @author Frederic Guillot
*/
interface ClientInterface
{
/**
* Send a HTML email
*
* @access public
* @param string $recipientEmail
* @param string $recipientName
* @param string $subject
* @param string $html
* @param string $authorName
* @param string $authorEmail
*/
public function sendEmail($recipientEmail, $recipientName, $subject, $html, $authorName, $authorEmail = '');
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Kanboard\Core\Mail\Transport;
use Swift_Message;
use Swift_Mailer;
use Swift_MailTransport;
use Swift_TransportException;
use Kanboard\Core\Base;
use Kanboard\Core\Mail\ClientInterface;
/**
* PHP Mail Handler
*
* @package Kanboard\Core\Mail\Transport
* @author Frederic Guillot
*/
class Mail extends Base implements ClientInterface
{
/**
* Send a HTML email
*
* @access public
* @param string $recipientEmail
* @param string $recipientName
* @param string $subject
* @param string $html
* @param string $authorName
* @param string $authorEmail
*/
public function sendEmail($recipientEmail, $recipientName, $subject, $html, $authorName, $authorEmail = '')
{
try {
$message = Swift_Message::newInstance()
->setSubject($subject)
->setFrom($this->helper->mail->getMailSenderAddress(), $authorName)
->setTo(array($recipientEmail => $recipientName));
if (! empty(MAIL_BCC)) {
$message->setBcc(MAIL_BCC);
}
$headers = $message->getHeaders();
// See https://tools.ietf.org/html/rfc3834#section-5
$headers->addTextHeader('Auto-Submitted', 'auto-generated');
if (! empty($authorEmail)) {
$message->setReplyTo($authorEmail);
}
$message->setBody($html, 'text/html');
Swift_Mailer::newInstance($this->getTransport())->send($message);
} catch (Swift_TransportException $e) {
$this->logger->error($e->getMessage());
}
}
/**
* Get SwiftMailer transport
*
* @access protected
* @return \Swift_Transport
*/
protected function getTransport()
{
return Swift_MailTransport::newInstance();
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Kanboard\Core\Mail\Transport;
use Swift_SendmailTransport;
/**
* PHP Mail Handler
*
* @package Kanboard\Core\Mail\Transport
* @author Frederic Guillot
*/
class Sendmail extends Mail
{
/**
* Get SwiftMailer transport
*
* @access protected
* @return \Swift_Transport
*/
protected function getTransport()
{
return Swift_SendmailTransport::newInstance(MAIL_SENDMAIL_COMMAND);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Kanboard\Core\Mail\Transport;
use Swift_SmtpTransport;
/**
* PHP Mail Handler
*
* @package Kanboard\Core\Mail\Transport
* @author Frederic Guillot
*/
class Smtp extends Mail
{
/**
* Get SwiftMailer transport
*
* @access protected
* @return \Swift_Transport
*/
protected function getTransport()
{
$transport = Swift_SmtpTransport::newInstance(MAIL_SMTP_HOSTNAME, MAIL_SMTP_PORT);
$transport->setUsername(MAIL_SMTP_USERNAME);
$transport->setPassword(MAIL_SMTP_PASSWORD);
if (!is_null(MAIL_SMTP_HELO_NAME)) {
$transport->setLocalDomain(MAIL_SMTP_HELO_NAME);
}
$transport->setEncryption(MAIL_SMTP_ENCRYPTION);
if (HTTP_VERIFY_SSL_CERTIFICATE === false) {
$transport->setStreamOptions(array(
'ssl' => array(
'allow_self_signed' => true,
'verify_peer' => false,
'verify_peer_name' => false,
)
));
}
return $transport;
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace Kanboard\Core;
use Parsedown;
use Pimple\Container;
/**
* Specific Markdown rules for Kanboard
*
* @package core
* @author norcnorc
* @author Frederic Guillot
*/
class Markdown extends Parsedown
{
/**
* Task links generated will use the project token instead
*
* @access private
* @var boolean
*/
private $isPublicLink = false;
/**
* Container
*
* @access private
* @var Container
*/
private $container;
/**
* Constructor
*
* @access public
* @param Container $container
* @param boolean $isPublicLink
*/
public function __construct(Container $container, $isPublicLink)
{
$this->isPublicLink = $isPublicLink;
$this->container = $container;
$this->BlockTypes['#'][0] = 'CustomHeader';
$this->InlineTypes['#'][] = 'TaskLink';
$this->InlineTypes['@'][] = 'UserLink';
$this->inlineMarkerList .= '#@';
}
protected function blockCustomHeader($Line)
{
if (preg_match('!#(\d+)!i', $Line['text'], $matches)) {
$link = $this->buildTaskLink($matches[1]);
if (! empty($link)) {
return [
'extent' => strlen($matches[0]),
'element' => [
'name' => 'a',
'text' => $matches[0],
'attributes' => ['href' => $link],
],
];
}
}
return $this->blockHeader($Line);
}
/**
* Handle Task Links
*
* Replace "#123" by a link to the task
*
* @access public
* @param array $Excerpt
* @return array|null
*/
protected function inlineTaskLink(array $Excerpt)
{
if (preg_match('!#(\d+)!i', $Excerpt['text'], $matches)) {
$link = $this->buildTaskLink($matches[1]);
if (! empty($link)) {
return array(
'extent' => strlen($matches[0]),
'element' => array(
'name' => 'a',
'text' => $matches[0],
'attributes' => array('href' => $link),
),
);
}
}
return null;
}
/**
* Handle User Mentions
*
* Replace "@username" by a link to the user
*
* @access public
* @param array $Excerpt
* @return array|null
*/
protected function inlineUserLink(array $Excerpt)
{
if (! $this->isPublicLink && preg_match('/^@([^\s,!:?]+)/', $Excerpt['text'], $matches)) {
$username = rtrim($matches[1], '.');
$user = $this->container['userCacheDecorator']->getByUsername($username);
if (! empty($user)) {
$url = $this->container['helper']->url->to('UserViewController', 'profile', array('user_id' => $user['id']));
$name = $user['name'] ?: $user['username'];
return array(
'extent' => strlen($username) + 1,
'element' => array(
'name' => 'a',
'text' => '@' . $username,
'attributes' => array(
'href' => $url,
'class' => 'user-mention-link',
'title' => $name,
'aria-label' => $name,
),
),
);
}
}
return null;
}
/**
* Build task link
*
* @access private
* @param integer $task_id
* @return string
*/
private function buildTaskLink($task_id)
{
if ($this->isPublicLink) {
$token = $this->container['memoryCache']->proxy($this->container['taskFinderModel'], 'getProjectToken', $task_id);
if (! empty($token)) {
return $this->container['helper']->url->to(
'TaskViewController',
'readonly',
array(
'token' => $token,
'task_id' => $task_id,
),
'',
true
);
}
return '';
}
return $this->container['helper']->url->to(
'TaskViewController',
'show',
array('task_id' => $task_id)
);
}
/**
* Exclude from nesting task links and user mentions for links
*
* @param array $Excerpt
* @return array|null
*/
protected function inlineLink($Excerpt)
{
$Inline = parent::inlineLink($Excerpt);
if (is_array($Inline)) {
array_push($Inline['element']['nonNestables'], 'TaskLink', 'UserLink');
}
return $Inline;
}
}
@@ -0,0 +1,32 @@
<?php
namespace Kanboard\Core\Notification;
/**
* Notification Interface
*
* @package Kanboard\Core\Notification
* @author Frederic Guillot
*/
interface NotificationInterface
{
/**
* Send notification to a user
*
* @access public
* @param array $user
* @param string $event_name
* @param array $event_data
*/
public function notifyUser(array $user, $event_name, array $event_data);
/**
* Send notification to a project
*
* @access public
* @param array $project
* @param string $event_name
* @param array $event_data
*/
public function notifyProject(array $project, $event_name, array $event_data);
}
+197
View File
@@ -0,0 +1,197 @@
<?php
namespace Kanboard\Core\ObjectStorage;
/**
* Local File Storage
*
* @package ObjectStorage
* @author Frederic Guillot
*/
class FileStorage implements ObjectStorageInterface
{
/**
* Base directory
*
* @access private
* @var string
*/
private $baseDir = '';
/**
* Constructor
*
* @access public
* @param string $baseDir
*/
public function __construct($baseDir)
{
$realBaseDir = realpath($baseDir);
if ($realBaseDir === false) {
throw new ObjectStorageException('Invalid base folder: '.$baseDir);
}
if (! is_dir($realBaseDir)) {
throw new ObjectStorageException('Base folder is not a directory: '.$baseDir);
}
$this->baseDir = $realBaseDir;
}
/**
* Fetch object contents
*
* @access public
* @throws ObjectStorageException
* @param string $key
* @return string
*/
public function get($key)
{
return file_get_contents($this->getRealFilePath($key));
}
/**
* Save object
*
* @access public
* @throws ObjectStorageException
* @param string $key
* @param string $blob
*/
public function put($key, &$blob)
{
$filename = $this->getSanitizedFilePath($key);
$this->createFolder($key);
if (file_put_contents($filename, $blob) === false) {
throw new ObjectStorageException('Unable to write the file: '.$filename);
}
}
/**
* Output directly object content
*
* @access public
* @throws ObjectStorageException
* @param string $key
*/
public function output($key)
{
readfile($this->getRealFilePath($key));
}
/**
* Move local file to object storage
*
* @access public
* @throws ObjectStorageException
* @param string $srcFilename
* @param string $key
* @return boolean
*/
public function moveFile($srcFilename, $key)
{
if (! file_exists($srcFilename)) {
throw new ObjectStorageException('Source file does not exist: '.$srcFilename);
}
$dstFilename = $this->getSanitizedFilePath($key);
$this->createFolder($key);
if (! rename($srcFilename, $dstFilename)) {
throw new ObjectStorageException('Unable to move the file: '.$srcFilename.' to '.$dstFilename);
}
return true;
}
/**
* Move uploaded file to object storage
*
* @access public
* @param string $srcFilename
* @param string $key
* @return boolean
*/
public function moveUploadedFile($srcFilename, $key)
{
if (! file_exists($srcFilename)) {
throw new ObjectStorageException('Source file does not exist: '.$srcFilename);
}
$dstFilename = $this->getSanitizedFilePath($key);
$this->createFolder($key);
return move_uploaded_file($srcFilename, $dstFilename);
}
/**
* Remove object
*
* @access public
* @param string $key
* @return boolean
*/
public function remove($key)
{
$filename = $this->getRealFilePath($key);
$result = unlink($filename);
// Remove parent folder if empty
$parentFolder = dirname($filename);
$files = glob($parentFolder.DIRECTORY_SEPARATOR.'*');
if ($files !== false && is_dir($parentFolder) && count($files) === 0) {
rmdir($parentFolder);
}
return $result;
}
private function createFolder(string $key)
{
$folder = strpos($key, DIRECTORY_SEPARATOR) !== false ? $this->baseDir.DIRECTORY_SEPARATOR.dirname($key) : $this->baseDir;
if (! is_dir($folder) && ! mkdir($folder, 0o755, true)) {
throw new ObjectStorageException('Unable to create folder: '.$folder);
}
}
private function getRealFilePath(string $key): string
{
$filename = $this->baseDir.DIRECTORY_SEPARATOR.$key;
// Resolve the real path and make sure the file exists
$realFilePath = realpath($filename);
if ($realFilePath === false) {
throw new ObjectStorageException('Invalid file path: '.$filename);
}
$this->validateBasePath($realFilePath);
return $realFilePath;
}
public function getSanitizedFilePath(string $key): string
{
$filename = $this->baseDir.DIRECTORY_SEPARATOR.$key;
$sanitizedKey = sanitize_path($filename);
if ($sanitizedKey === false) {
throw new ObjectStorageException('Invalid file path: '.$key);
}
$this->validateBasePath($sanitizedKey);
return $sanitizedKey;
}
private function validateBasePath(string $filePath)
{
if (strpos($filePath, $this->baseDir) !== 0) {
throw new ObjectStorageException('File '.$filePath.' is not in base directory: '.$this->baseDir);
}
}
}
@@ -0,0 +1,9 @@
<?php
namespace Kanboard\Core\ObjectStorage;
use Exception;
class ObjectStorageException extends Exception
{
}
@@ -0,0 +1,67 @@
<?php
namespace Kanboard\Core\ObjectStorage;
/**
* Object Storage Interface
*
* @package ObjectStorage
* @author Frederic Guillot
*/
interface ObjectStorageInterface
{
/**
* Fetch object contents
*
* @access public
* @param string $key
* @return string
*/
public function get($key);
/**
* Save object
*
* @access public
* @param string $key
* @param string $blob
*/
public function put($key, &$blob);
/**
* Output directly object content
*
* @access public
* @param string $key
*/
public function output($key);
/**
* Move local file to object storage
*
* @access public
* @param string $filename
* @param string $key
* @return boolean
*/
public function moveFile($filename, $key);
/**
* Move uploaded file to object storage
*
* @access public
* @param string $filename
* @param string $key
* @return boolean
*/
public function moveUploadedFile($filename, $key);
/**
* Remove object
*
* @access public
* @param string $key
* @return boolean
*/
public function remove($key);
}
+543
View File
@@ -0,0 +1,543 @@
<?php
namespace Kanboard\Core;
use Kanboard\Core\Filter\FormatterInterface;
use Pimple\Container;
use PicoDb\Table;
/**
* Paginator Helper
*
* @package Kanboard\Core
* @author Frederic Guillot
*/
class Paginator
{
/**
* Container instance
*
* @access private
* @var \Pimple\Container
*/
private $container;
/**
* Total number of items
*
* @access private
* @var integer
*/
private $total = 0;
/**
* Page number
*
* @access private
* @var integer
*/
private $page = 1;
/**
* Offset
*
* @access private
* @var integer
*/
private $offset = 0;
/**
* Limit
*
* @access private
* @var integer
*/
private $limit = 0;
/**
* Sort by this column
*
* @access private
* @var string
*/
private $order = '';
/**
* Sorting direction
*
* @access private
* @var string
*/
private $direction = 'ASC';
/**
* Slice of items
*
* @access private
* @var array
*/
private $items = array();
/**
* PicoDb Table instance
*
* @access private
* @var \Picodb\Table
*/
private $query = null;
/**
* Controller name
*
* @access private
* @var string
*/
private $controller = '';
/**
* Action name
*
* @access private
* @var string
*/
private $action = '';
/**
* URL anchor
*
* @access private
* @var string
*/
private $anchor = '';
/**
* Url params
*
* @access private
* @var array
*/
private $params = array();
/**
* @var FormatterInterface
*/
protected $formatter = null;
/**
* Constructor
*
* @access public
* @param \Pimple\Container $container
*/
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* Set a PicoDb query
*
* @access public
* @param \PicoDb\Table
* @return $this
*/
public function setQuery(Table $query)
{
$this->query = $query;
$this->total = $this->query->count();
return $this;
}
/**
* Set Formatter
*
* @param FormatterInterface $formatter
* @return $this
*/
public function setFormatter(FormatterInterface $formatter)
{
$this->formatter = $formatter;
return $this;
}
/**
* Execute a PicoDb query
*
* @access public
* @return array
*/
public function executeQuery()
{
if ($this->query !== null) {
$this->query
->offset($this->offset)
->limit($this->limit);
if (preg_match('/^[a-zA-Z0-9._]+$/', $this->order)) {
$this->query->orderBy($this->order, $this->direction);
} else {
$this->order = '';
}
if ($this->formatter !== null) {
return $this->formatter->withQuery($this->query)->format();
} else {
return $this->query->findAll();
}
}
return array();
}
/**
* Set url parameters
*
* @access public
* @param string $controller
* @param string $action
* @param array $params
* @param string $anchor
* @return $this
*/
public function setUrl($controller, $action, array $params = array(), $anchor = '')
{
$this->controller = $controller;
$this->action = $action;
$this->params = $params;
$this->anchor = $anchor;
return $this;
}
/**
* Add manually items
*
* @access public
* @param array $items
* @return $this
*/
public function setCollection(array $items)
{
$this->items = $items;
return $this;
}
/**
* Return the items
*
* @access public
* @return array
*/
public function getCollection()
{
return $this->items ?: $this->executeQuery();
}
/**
* Set the total number of items
*
* @access public
* @param integer $total
* @return $this
*/
public function setTotal($total)
{
$this->total = $total;
return $this;
}
/**
* Get the total number of items
*
* @access public
* @return integer
*/
public function getTotal()
{
return $this->total;
}
/**
* Set the default page number
*
* @access public
* @param integer $page
* @return $this
*/
public function setPage($page)
{
$this->page = $page;
return $this;
}
/**
* Get the number of current page
*
* @access public
* @return integer
*/
public function getPage()
{
return $this->page;
}
/**
* Set the default column order
*
* @access public
* @param string $order
* @return $this
*/
public function setOrder($order)
{
$this->order = $order;
return $this;
}
/**
* Set the default sorting direction
*
* @access public
* @param string $direction
* @return $this
*/
public function setDirection($direction)
{
$this->direction = $direction;
return $this;
}
/**
* Set the maximum number of items per page
*
* @access public
* @param integer $limit
* @return $this
*/
public function setMax($limit)
{
$this->limit = $limit;
return $this;
}
/**
* Get the maximum number of items per page.
*
* @return int
*/
public function getMax()
{
return $this->limit;
}
/**
* Return true if the collection is empty
*
* @access public
* @return boolean
*/
public function isEmpty()
{
return $this->total === 0;
}
/**
* Execute the offset calculation only if the $condition is true
*
* @access public
* @param boolean $condition
* @return $this
*/
public function calculateOnlyIf($condition)
{
if ($condition) {
$this->calculate();
}
return $this;
}
/**
* Calculate the offset value according to url params and the page number
*
* @access public
* @return $this
*/
public function calculate()
{
$this->page = $this->container['request']->getIntegerParam('page', 1);
$this->direction = $this->container['request']->getStringParam('direction', $this->direction);
$this->order = $this->container['request']->getStringParam('order', $this->order);
if ($this->page < 1) {
$this->page = 1;
}
$this->offset = (int) (($this->page - 1) * $this->limit);
return $this;
}
/**
* Get url params for link generation
*
* @access public
* @param integer $page
* @param string $order
* @param string $direction
* @return string
*/
public function getUrlParams($page, $order, $direction)
{
$params = array(
'page' => $page,
'order' => $order,
'direction' => $direction,
);
return array_merge($this->params, $params);
}
/**
* Generate the previous link
*
* @access public
* @return string
*/
public function generatePreviousLink()
{
$html = '<span class="pagination-previous">';
if ($this->offset > 0) {
$html .= $this->container['helper']->url->link(
'&larr; '.t('Previous'),
$this->controller,
$this->action,
$this->getUrlParams($this->page - 1, $this->order, $this->direction),
false,
'js-modal-replace',
t('Previous'),
false,
$this->anchor
);
} else {
$html .= '&larr; '.t('Previous');
}
$html .= '</span>';
return $html;
}
/**
* Generate the next link
*
* @access public
* @return string
*/
public function generateNextLink()
{
$html = '<span class="pagination-next">';
if (($this->total - $this->offset) > $this->limit) {
$html .= $this->container['helper']->url->link(
t('Next').' &rarr;',
$this->controller,
$this->action,
$this->getUrlParams($this->page + 1, $this->order, $this->direction),
false,
'js-modal-replace',
t('Next'),
false,
$this->anchor
);
} else {
$html .= t('Next').' &rarr;';
}
$html .= '</span>';
return $html;
}
/**
* Generate the page showing.
*
* @access public
* @return string
*/
public function generatePageShowing()
{
return '<span class="pagination-showing">'.t('Showing %d-%d of %d', (($this->getPage() - 1) * $this->getMax() + 1), min($this->getTotal(), $this->getPage() * $this->getMax()), $this->getTotal()).'</span>';
}
/**
* Return true if there is no pagination to show
*
* @access public
* @return boolean
*/
public function hasNothingToShow()
{
return $this->offset === 0 && ($this->total - $this->offset) <= $this->limit;
}
/**
* Generation pagination links
*
* @access public
* @return string
*/
public function toHtml()
{
$html = '';
if (! $this->hasNothingToShow()) {
$html .= '<div class="pagination">';
$html .= $this->generatePageShowing();
$html .= $this->generatePreviousLink();
$html .= $this->generateNextLink();
$html .= '</div>';
}
return $html;
}
/**
* Magic method to output pagination links
*
* @access public
* @return string
*/
public function __toString()
{
return $this->toHtml();
}
/**
* Column sorting
*
* @param string $label Column title
* @param string $column SQL column name
* @return string
*/
public function order($label, $column)
{
$prefix = '';
$direction = 'ASC';
if ($this->order === $column) {
$prefix = $this->direction === 'DESC' ? '&#9660; ' : '&#9650; ';
$direction = $this->direction === 'DESC' ? 'ASC' : 'DESC';
}
return $prefix.$this->container['helper']->url->link(
$label,
$this->controller,
$this->action,
$this->getUrlParams($this->page, $column, $direction),
false,
'js-modal-replace'
);
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace Kanboard\Core\Plugin;
/**
* Plugin Base class
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
abstract class Base extends \Kanboard\Core\Base
{
/**
* Method called for each request
*
* @abstract
* @access public
*/
abstract public function initialize();
/**
* Override default CSP rules
*
* @access public
* @param array $rules
*/
public function setContentSecurityPolicy(array $rules)
{
$this->container['cspRules'] = $rules;
}
/**
* Returns all classes that needs to be stored in the DI container
*
* @access public
* @return array
*/
public function getClasses()
{
return array();
}
/**
* Returns all helper classes that needs to be stored in the DI container
*
* @access public
* @return array
*/
public function getHelpers()
{
return array();
}
/**
* Listen on internal events
*
* @access public
* @param string $event
* @param callable $callback
*/
public function on($event, $callback)
{
$container = $this->container;
$this->dispatcher->addListener($event, function () use ($container, $callback) {
call_user_func($callback, $container);
});
}
/**
* Get plugin name
*
* This method should be overridden by your Plugin class
*
* @access public
* @return string
*/
public function getPluginName()
{
return ucfirst(substr(get_called_class(), 16, -7));
}
/**
* Get plugin description
*
* This method should be overridden by your Plugin class
*
* @access public
* @return string
*/
public function getPluginDescription()
{
return '';
}
/**
* Get plugin author
*
* This method should be overridden by your Plugin class
*
* @access public
* @return string
*/
public function getPluginAuthor()
{
return '?';
}
/**
* Get plugin version
*
* This method should be overridden by your Plugin class
*
* @access public
* @return string
*/
public function getPluginVersion()
{
return '?';
}
/**
* Get plugin homepage
*
* This method should be overridden by your Plugin class
*
* @access public
* @return string
*/
public function getPluginHomepage()
{
return '';
}
/**
* Get application compatibility version
*
* Examples: >=1.0.36, 1.0.37, APP_VERSION
*
* @access public
* @return string
*/
public function getCompatibleVersion()
{
return APP_VERSION;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Kanboard\Core\Plugin;
use Kanboard\Core\Base as BaseCore;
/**
* Class Directory
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
class Directory extends BaseCore
{
/**
* Get all plugins available
*
* @access public
* @param string $url
* @return array
*/
public function getAvailablePlugins($url = PLUGIN_API_URL)
{
$plugins = $this->httpClient->getJson($url);
$plugins = array_filter($plugins, array($this, 'isCompatible'));
$plugins = array_filter($plugins, array($this, 'isInstallable'));
return $plugins;
}
/**
* Filter plugins
*
* @param array $plugin
* @param string $appVersion
* @return bool
*/
public function isCompatible(array $plugin, $appVersion = APP_VERSION)
{
return Version::isCompatible($plugin['compatible_version'], $appVersion);
}
/**
* Filter plugins
*
* @param array $plugin
* @return bool
*/
public function isInstallable(array $plugin)
{
return $plugin['remote_install'];
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace Kanboard\Core\Plugin;
/**
* Plugin Hooks Handler
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
class Hook
{
/**
* List of hooks
*
* @access private
* @var array
*/
private $hooks = array();
/**
* Bind something on a hook
*
* @access public
* @param string $hook
* @param mixed $value
*/
public function on($hook, $value)
{
if (! isset($this->hooks[$hook])) {
$this->hooks[$hook] = array();
}
$this->hooks[$hook][] = $value;
}
/**
* Get all bindings for a hook
*
* @access public
* @param string $hook
* @return array
*/
public function getListeners($hook)
{
return isset($this->hooks[$hook]) ? $this->hooks[$hook] : array();
}
/**
* Return true if the hook is used
*
* @access public
* @param string $hook
* @return boolean
*/
public function exists($hook)
{
return isset($this->hooks[$hook]);
}
/**
* Merge listener results with input array
*
* @access public
* @param string $hook
* @param array $values
* @param array $params
* @return array
*/
public function merge($hook, array &$values, array $params = array())
{
foreach ($this->getListeners($hook) as $listener) {
$result = call_user_func_array($listener, $params);
if (is_array($result) && ! empty($result)) {
$values = array_merge($values, $result);
}
}
return $values;
}
/**
* Execute only first listener
*
* @access public
* @param string $hook
* @param array $params
* @return mixed
*/
public function first($hook, array $params = array())
{
foreach ($this->getListeners($hook) as $listener) {
return call_user_func_array($listener, $params);
}
return null;
}
/**
* Hook with reference
*
* @access public
* @param string $hook
* @param mixed $param
* @return mixed
*/
public function reference($hook, &$param)
{
foreach ($this->getListeners($hook) as $listener) {
$listener($param);
}
return $param;
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace Kanboard\Core\Plugin;
use ZipArchive;
use Kanboard\Core\Tool;
/**
* Class Installer
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
class Installer extends \Kanboard\Core\Base
{
/**
* Return true if Kanboard is configured to install plugins
*
* @static
* @access public
* @return bool
*/
public static function isConfigured()
{
return PLUGIN_INSTALLER && is_writable(PLUGINS_DIR) && extension_loaded('zip');
}
/**
* Install a plugin
*
* @access public
* @param string $archiveUrl
* @throws PluginInstallerException
*/
public function install($archiveUrl)
{
$zip = $this->downloadPluginArchive($archiveUrl);
if (! $zip->extractTo(PLUGINS_DIR)) {
$this->cleanupArchive($zip);
throw new PluginInstallerException(t('Unable to extract plugin archive.'));
}
$this->cleanupArchive($zip);
}
/**
* Uninstall a plugin
*
* @access public
* @param string $pluginId
* @throws PluginInstallerException
*/
public function uninstall($pluginId)
{
$pluginFolder = PLUGINS_DIR.DIRECTORY_SEPARATOR.basename($pluginId);
if (! file_exists($pluginFolder)) {
throw new PluginInstallerException(t('Plugin not found.'));
}
if (! is_writable($pluginFolder)) {
throw new PluginInstallerException(e('You don\'t have the permission to remove this plugin.'));
}
Tool::removeAllFiles($pluginFolder);
}
/**
* Update a plugin
*
* @access public
* @param string $archiveUrl
* @throws PluginInstallerException
*/
public function update($archiveUrl)
{
$zip = $this->downloadPluginArchive($archiveUrl);
$firstEntry = $zip->statIndex(0);
$this->uninstall($firstEntry['name']);
if (! $zip->extractTo(PLUGINS_DIR)) {
$this->cleanupArchive($zip);
throw new PluginInstallerException(t('Unable to extract plugin archive.'));
}
$this->cleanupArchive($zip);
}
/**
* Download archive from URL
*
* @access protected
* @param string $archiveUrl
* @return ZipArchive
* @throws PluginInstallerException
*/
protected function downloadPluginArchive($archiveUrl)
{
if (!preg_match('/^https?:\/\//', $archiveUrl) || !filter_var($archiveUrl, FILTER_VALIDATE_URL)) {
throw new PluginInstallerException(t('This URL is invalid'));
}
$archiveData = $this->httpClient->get($archiveUrl);
$archiveFile = tempnam(ini_get('upload_tmp_dir') ? ini_get('upload_tmp_dir') : sys_get_temp_dir(), 'kb_plugin');
if (empty($archiveData)) {
unlink($archiveFile);
throw new PluginInstallerException(t('Unable to download plugin archive.'));
}
if (file_put_contents($archiveFile, $archiveData) === false) {
unlink($archiveFile);
throw new PluginInstallerException(t('Unable to write temporary file for plugin.'));
}
$zip = new ZipArchive();
if ($zip->open($archiveFile) !== true) {
unlink($archiveFile);
throw new PluginInstallerException(t('Unable to open plugin archive.'));
}
if ($zip->numFiles === 0) {
unlink($archiveFile);
throw new PluginInstallerException(t('There is no file in the plugin archive.'));
}
return $zip;
}
/**
* Remove archive file
*
* @access protected
* @param ZipArchive $zip
*/
protected function cleanupArchive(ZipArchive $zip)
{
$filename = $zip->filename;
$zip->close();
unlink($filename);
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace Kanboard\Core\Plugin;
use Composer\Autoload\ClassLoader;
use DirectoryIterator;
use Exception;
use LogicException;
use Kanboard\Core\Tool;
/**
* Plugin Loader
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
class Loader extends \Kanboard\Core\Base
{
/**
* Plugin instances
*
* @access protected
* @var array
*/
protected $plugins = array();
protected $incompatiblePlugins = array();
/**
* Get list of loaded plugins
*
* @access public
* @return Base[]
*/
public function getPlugins()
{
return $this->plugins;
}
/**
* Get list of not compatible plugins
*
* @access public
* @return Base[]
*/
public function getIncompatiblePlugins()
{
return $this->incompatiblePlugins;
}
/**
* Scan plugin folder and load plugins
*
* @access public
*/
public function scan()
{
if (file_exists(PLUGINS_DIR)) {
$loader = new ClassLoader();
$loader->addPsr4('Kanboard\Plugin\\', PLUGINS_DIR);
$loader->register();
$dir = new DirectoryIterator(PLUGINS_DIR);
foreach ($dir as $fileInfo) {
if ($fileInfo->isDir() && substr($fileInfo->getFilename(), 0, 1) !== '.') {
$pluginName = $fileInfo->getFilename();
$this->initializePlugin($pluginName);
}
}
}
}
/**
* Load plugin schema
*
* @access public
* @param string $pluginName
*/
public function loadSchema($pluginName)
{
if (SchemaHandler::hasSchema($pluginName)) {
$schemaHandler = new SchemaHandler($this->container);
$schemaHandler->loadSchema($pluginName);
}
}
/**
* Load plugin
*
* @access public
* @throws LogicException
* @param string $pluginName
* @return Base
*/
public function loadPlugin($pluginName)
{
$className = '\Kanboard\Plugin\\'.$pluginName.'\\Plugin';
if (! class_exists($className)) {
throw new LogicException('Unable to load this plugin class: '.$className);
}
return new $className($this->container);
}
/**
* Initialize plugin
*
* @access public
* @param string $pluginName
*/
public function initializePlugin($pluginName)
{
try {
$plugin = $this->loadPlugin($pluginName);
if (Version::isCompatible($plugin->getCompatibleVersion(), APP_VERSION)) {
$this->loadSchema($pluginName);
if (method_exists($plugin, 'onStartup')) {
$this->dispatcher->addListener('app.bootstrap', array($plugin, 'onStartup'));
}
Tool::buildDIC($this->container, $plugin->getClasses());
Tool::buildDICHelpers($this->container, $plugin->getHelpers());
$plugin->initialize();
$this->plugins[$pluginName] = $plugin;
} else {
$this->incompatiblePlugins[$pluginName] = $plugin;
$this->logger->error($pluginName.' is not compatible with this version');
}
} catch (Exception $e) {
$this->logger->critical($pluginName.': '.$e->getMessage());
}
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace Kanboard\Core\Plugin;
use Exception;
/**
* Class PluginException
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
class PluginException extends Exception
{
}
@@ -0,0 +1,13 @@
<?php
namespace Kanboard\Core\Plugin;
/**
* Class PluginInstallerException
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
class PluginInstallerException extends PluginException
{
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace Kanboard\Core\Plugin;
use PDOException;
use RuntimeException;
/**
* Class SchemaHandler
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
class SchemaHandler extends \Kanboard\Core\Base
{
/**
* Schema version table for plugins
*
* @var string
*/
const TABLE_SCHEMA = 'plugin_schema_versions';
/**
* Get schema filename
*
* @static
* @access public
* @param string $pluginName
* @return string
*/
public static function getSchemaFilename($pluginName)
{
return PLUGINS_DIR.'/'.$pluginName.'/Schema/'.ucfirst(DB_DRIVER).'.php';
}
/**
* Return true if the plugin has schema
*
* @static
* @access public
* @param string $pluginName
* @return boolean
*/
public static function hasSchema($pluginName)
{
return file_exists(self::getSchemaFilename($pluginName));
}
/**
* Load plugin schema
*
* @access public
* @param string $pluginName
*/
public function loadSchema($pluginName)
{
require_once self::getSchemaFilename($pluginName);
$this->migrateSchema($pluginName);
}
/**
* Execute plugin schema migrations
*
* @access public
* @param string $pluginName
*/
public function migrateSchema($pluginName)
{
$lastVersion = constant('\Kanboard\Plugin\\'.$pluginName.'\Schema\VERSION');
$currentVersion = $this->getSchemaVersion($pluginName);
try {
$this->db->startTransaction();
$this->db->getDriver()->disableForeignKeys();
for ($i = $currentVersion + 1; $i <= $lastVersion; $i++) {
$functionName = '\Kanboard\Plugin\\'.$pluginName.'\Schema\version_'.$i;
if (function_exists($functionName)) {
call_user_func($functionName, $this->db->getConnection());
}
}
$this->db->getDriver()->enableForeignKeys();
$this->db->closeTransaction();
$this->setSchemaVersion($pluginName, $i - 1);
} catch (PDOException $e) {
$this->db->cancelTransaction();
$this->db->getDriver()->enableForeignKeys();
throw new RuntimeException('Unable to migrate schema for the plugin: '.$pluginName.' => '.$e->getMessage());
}
}
/**
* Get current plugin schema version
*
* @access public
* @param string $plugin
* @return integer
*/
public function getSchemaVersion($plugin)
{
return (int) $this->db->table(self::TABLE_SCHEMA)->eq('plugin', strtolower($plugin))->findOneColumn('version');
}
/**
* Save last plugin schema version
*
* @access public
* @param string $plugin
* @param integer $version
* @return boolean
*/
public function setSchemaVersion($plugin, $version)
{
$dictionary = array(
strtolower($plugin) => $version
);
return $this->db->getDriver()->upsert(self::TABLE_SCHEMA, 'plugin', 'version', $dictionary);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Kanboard\Core\Plugin;
/**
* Class Version
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
class Version
{
/**
* Check plugin version compatibility with application version
*
* @param string $pluginCompatibleVersion
* @param string $appVersion
* @return bool
*/
public static function isCompatible($pluginCompatibleVersion, $appVersion = APP_VERSION)
{
if (strpos($appVersion, 'master') !== false || strpos($appVersion, 'main') !== false) {
return true;
}
$appVersion = str_replace('v', '', $appVersion);
$pluginCompatibleVersion = str_replace('v', '', $pluginCompatibleVersion);
foreach (array('>=', '>', '<=', '<') as $operator) {
if (strpos($pluginCompatibleVersion, $operator) === 0) {
$pluginVersion = substr($pluginCompatibleVersion, strlen($operator));
return version_compare($appVersion, $pluginVersion, $operator);
}
}
return $pluginCompatibleVersion === $appVersion;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Kanboard\Core\Queue;
use Exception;
use Kanboard\Core\Base;
use Kanboard\Job\BaseJob;
use SimpleQueue\Job;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Class JobHandler
*
* @package Kanboard\Core\Queue
* @author Frederic Guillot
*/
class JobHandler extends Base
{
/**
* Serialize a job
*
* @access public
* @param BaseJob $job
* @return Job
*/
public function serializeJob(BaseJob $job)
{
return new Job(array(
'class' => get_class($job),
'params' => $job->getJobParams(),
'user_id' => $this->userSession->getId(),
));
}
/**
* Execute a job
*
* @access public
* @param Job $job
*/
public function executeJob(Job $job)
{
$payload = $job->getBody();
try {
$className = $payload['class'];
$this->prepareJobSession($payload['user_id']);
$this->prepareJobEnvironment();
if (DEBUG) {
$this->logger->debug(__METHOD__.' Received job => '.$className.' ('.getmypid().')');
$this->logger->debug(__METHOD__.' => '.json_encode($payload));
}
$worker = new $className($this->container);
call_user_func_array(array($worker, 'execute'), $payload['params']);
} catch (Exception $e) {
$this->logger->error(__METHOD__.': Error during job execution: '.$e->getMessage());
$this->logger->error(__METHOD__ .' => '.json_encode($payload));
}
}
/**
* Create the session for the job
*
* @access protected
* @param integer $user_id
*/
protected function prepareJobSession($user_id)
{
session_flush();
if ($user_id > 0) {
$user = $this->userModel->getById($user_id);
$this->userSession->initialize($user);
}
}
/**
* Flush in-memory caching and specific events
*
* @access protected
*/
protected function prepareJobEnvironment()
{
$this->memoryCache->flush();
$this->actionManager->removeEvents();
$this->dispatcher->dispatch(new Event(), 'app.bootstrap');
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace Kanboard\Core\Queue;
use Kanboard\Core\Base;
use Kanboard\Job\BaseJob;
use LogicException;
use SimpleQueue\Queue;
/**
* Class QueueManager
*
* @package Kanboard\Core\Queue
* @author Frederic Guillot
*/
class QueueManager extends Base
{
/**
* @var Queue
*/
protected $queue = null;
/**
* Set queue driver
*
* @access public
* @param Queue $queue
* @return $this
*/
public function setQueue(Queue $queue)
{
$this->queue = $queue;
return $this;
}
/**
* Send a new job to the queue
*
* @access public
* @param BaseJob $job
* @return $this
*/
public function push(BaseJob $job)
{
$jobClassName = get_class($job);
if ($this->queue !== null) {
$this->logger->debug(__METHOD__.': Job pushed in queue: '.$jobClassName);
$this->queue->push(JobHandler::getInstance($this->container)->serializeJob($job));
} else {
$this->logger->debug(__METHOD__.': Job executed synchronously: '.$jobClassName);
call_user_func_array(array($job, 'execute'), $job->getJobParams());
}
return $this;
}
/**
* Wait for new jobs
*
* @access public
* @throws LogicException
*/
public function listen()
{
if ($this->queue === null) {
throw new LogicException('No queue driver defined or unable to connect to broker!');
}
while ($job = $this->queue->pull()) {
JobHandler::getInstance($this->container)->executeJob($job);
$this->queue->completed($job);
}
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace Kanboard\Core\Security;
/**
* Access Map Definition
*
* @package security
* @author Frederic Guillot
*/
class AccessMap
{
/**
* Default role
*
* @access private
* @var string
*/
private $defaultRole = '';
/**
* Role hierarchy
*
* @access private
* @var array
*/
private $hierarchy = array();
/**
* Access map
*
* @access private
* @var array
*/
private $map = array();
/**
* Define the default role when nothing match
*
* @access public
* @param string $role
* @return AccessMap
*/
public function setDefaultRole($role)
{
$this->defaultRole = $role;
return $this;
}
/**
* Define role hierarchy
*
* @access public
* @param string $role
* @param array $subroles
* @return AccessMap
*/
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;
}
/**
* Get the highest role from a list
*
* @access public
* @param array $roles
* @return string
*/
public function getHighestRole(array $roles)
{
$rank = array();
foreach ($roles as $role) {
$rank[$role] = count($this->getRoleHierarchy($role));
}
asort($rank);
return key($rank);
}
/**
* 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 AccessMap
*/
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 string $role
* @return AccessMap
*/
private function addRule($controller, $method, $role)
{
$controller = strtolower($controller);
$method = strtolower($method);
if (! isset($this->map[$controller])) {
$this->map[$controller] = array();
}
$this->map[$controller][$method] = $role;
return $this;
}
/**
* Get roles that match the given controller/method
*
* @access public
* @param string $controller
* @param string $method
* @return array
*/
public function getRoles($controller, $method)
{
$controller = strtolower($controller);
$method = strtolower($method);
foreach (array($method, '*') as $key) {
if (isset($this->map[$controller][$key])) {
return $this->getRoleHierarchy($this->map[$controller][$key]);
}
}
return $this->getRoleHierarchy($this->defaultRole);
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
namespace Kanboard\Core\Security;
use LogicException;
use Kanboard\Core\Base;
use Kanboard\Event\AuthFailureEvent;
use Kanboard\Event\AuthSuccessEvent;
/**
* Authentication Manager
*
* @package security
* @author Frederic Guillot
*/
class AuthenticationManager extends Base
{
/**
* Event names
*
* @var string
*/
const EVENT_SUCCESS = 'auth.success';
const EVENT_FAILURE = 'auth.failure';
/**
* List of authentication providers
*
* @access private
* @var array
*/
private $providers = array();
public function reset()
{
$this->providers = [];
}
/**
* Register a new authentication provider
*
* @access public
* @param AuthenticationProviderInterface $provider
* @return AuthenticationManager
*/
public function register(AuthenticationProviderInterface $provider)
{
$this->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 instanceof OptionalAuthenticationProviderInterface && ! $provider->isEnabled()) {
continue;
}
if (! $provider->isValidSession()) {
$this->logger->debug('Invalidate session for '.$this->userSession->getUsername());
session_flush();
$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 instanceof OptionalAuthenticationProviderInterface && ! $provider->isEnabled()) {
continue;
}
if ($provider->authenticate() && $this->userProfile->initialize($provider->getUser())) {
$this->dispatcher->dispatch(new AuthSuccessEvent($provider->getName()), self::EVENT_SUCCESS);
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(new AuthSuccessEvent($provider->getName()), self::EVENT_SUCCESS);
}
return true;
}
}
if ($fireEvent) {
$this->dispatcher->dispatch(new AuthFailureEvent($username), self::EVENT_FAILURE);
}
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(new AuthSuccessEvent($provider->getName()), self::EVENT_SUCCESS);
return true;
}
$this->dispatcher->dispatch(new AuthFailureEvent, self::EVENT_FAILURE);
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);
});
}
}
@@ -0,0 +1,28 @@
<?php
namespace Kanboard\Core\Security;
/**
* Authentication Provider Interface
*
* @package security
* @author Frederic Guillot
*/
interface AuthenticationProviderInterface
{
/**
* Get authentication provider name
*
* @access public
* @return string
*/
public function getName();
/**
* Authenticate the user
*
* @access public
* @return boolean
*/
public function authenticate();
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Kanboard\Core\Security;
/**
* Authorization Handler
*
* @package security
* @author Frederic Guillot
*/
class Authorization
{
/**
* Access Map
*
* @access private
* @var AccessMap
*/
private $accessMap;
/**
* Constructor
*
* @access public
* @param AccessMap $accessMap
*/
public function __construct(AccessMap $accessMap)
{
$this->accessMap = $accessMap;
}
/**
* 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->accessMap->getRoles($controller, $method);
return in_array($role, $roles);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Kanboard\Core\Security;
/**
* OAuth2 Authentication Provider Interface
*
* @package security
* @author Frederic Guillot
*/
interface OAuthAuthenticationProviderInterface extends AuthenticationProviderInterface
{
/**
* Get user object
*
* @access public
* @return \Kanboard\Core\User\UserProviderInterface
*/
public function getUser();
/**
* Unlink user
*
* @access public
* @param integer $userId
* @return bool
*/
public function unlink($userId);
/**
* Get configured OAuth2 service
*
* @access public
* @return \Kanboard\Core\Http\OAuth2
*/
public function getService();
/**
* Set OAuth2 code
*
* @access public
* @param string $code
* @return OAuthAuthenticationProviderInterface
*/
public function setCode($code);
}
@@ -0,0 +1,20 @@
<?php
namespace Kanboard\Core\Security;
/**
* Optional Authentication Provider Interface
*
* @package security
* @author Frederic Guillot
*/
interface OptionalAuthenticationProviderInterface
{
/**
* Check if the authentication provider should be used
*
* @access public
* @return boolean
*/
public function isEnabled();
}
@@ -0,0 +1,36 @@
<?php
namespace Kanboard\Core\Security;
/**
* Password Authentication Provider Interface
*
* @package security
* @author Frederic Guillot
*/
interface PasswordAuthenticationProviderInterface extends AuthenticationProviderInterface
{
/**
* Get user object
*
* @access public
* @return \Kanboard\Core\User\UserProviderInterface
*/
public function getUser();
/**
* Set username
*
* @access public
* @param string $username
*/
public function setUsername($username);
/**
* Set password
*
* @access public
* @param string $password
*/
public function setPassword($password);
}
@@ -0,0 +1,60 @@
<?php
namespace Kanboard\Core\Security;
/**
* Post Authentication Provider Interface
*
* @package security
* @author Frederic Guillot
*/
interface PostAuthenticationProviderInterface extends AuthenticationProviderInterface
{
/**
* Called only one time before to prompt the user for pin code
*
* @access public
*/
public function beforeCode();
/**
* Set user pin-code
*
* @access public
* @param string $code
*/
public function setCode($code);
/**
* Generate secret if necessary
*
* @access public
* @return string
*/
public function generateSecret();
/**
* Set secret token (fetched from user profile)
*
* @access public
* @param string $secret
*/
public function setSecret($secret);
/**
* Get secret token (will be saved in user profile)
*
* @access public
* @return string
*/
public function getSecret();
/**
* Get key url (empty if no url can be provided)
*
* @access public
* @param string $label
* @return string
*/
public function getKeyUrl($label);
}
@@ -0,0 +1,20 @@
<?php
namespace Kanboard\Core\Security;
/**
* Pre-Authentication Provider Interface
*
* @package security
* @author Frederic Guillot
*/
interface PreAuthenticationProviderInterface extends AuthenticationProviderInterface
{
/**
* Get user object
*
* @access public
* @return \Kanboard\Core\User\UserProviderInterface
*/
public function getUser();
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Kanboard\Core\Security;
/**
* Role Definitions
*
* @package security
* @author Frederic Guillot
*/
class Role
{
const APP_ADMIN = 'app-admin';
const APP_MANAGER = 'app-manager';
const APP_USER = 'app-user';
const APP_PUBLIC = 'app-public';
const PROJECT_MANAGER = 'project-manager';
const PROJECT_MEMBER = 'project-member';
const PROJECT_VIEWER = 'project-viewer';
/**
* Get application roles
*
* @access public
* @return array
*/
public function getApplicationRoles()
{
return array(
self::APP_ADMIN => 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'),
);
}
/**
* Check if the given role is custom or not
*
* @access public
* @param string $role
* @return bool
*/
public function isCustomProjectRole($role)
{
return ! empty($role) && $role !== self::PROJECT_MANAGER && $role !== self::PROJECT_MEMBER && $role !== self::PROJECT_VIEWER;
}
/**
* Get role name
*
* @access public
* @param string $role
* @return string
*/
public function getRoleName($role)
{
$roles = $this->getApplicationRoles() + $this->getProjectRoles();
return isset($roles[$role]) ? $roles[$role] : t('Unknown');
}
}
@@ -0,0 +1,20 @@
<?php
namespace Kanboard\Core\Security;
/**
* Session Check Provider Interface
*
* @package security
* @author Frederic Guillot
*/
interface SessionCheckProviderInterface
{
/**
* Check if the user session is valid
*
* @access public
* @return boolean
*/
public function isValidSession();
}
+135
View File
@@ -0,0 +1,135 @@
<?php
namespace Kanboard\Core\Security;
use Kanboard\Core\Base;
/**
* Token Handler
*
* @package security
* @author Frederic Guillot
*/
class Token extends Base
{
protected static $KEY_LENGTH = 32;
protected static $NONCE_LENGTH = 16;
protected static $HMAC_ALGO = 'sha256';
protected static $HMAC_LENGTH = 16;
/**
* Generate a random token with different methods: openssl or /dev/urandom or fallback to uniqid()
*
* @static
* @access public
* @return string Random token
*/
public static function getToken($length = 30)
{
return bin2hex(random_bytes($length));
}
/**
* Generate and store a one-time CSRF token
*
* @access public
* @return string Random token
*/
public function getCSRFToken()
{
return $this->createSessionToken('csrf');
}
/**
* Generate and store a reusable CSRF token
*
* @access public
* @return string
*/
public function getReusableCSRFToken()
{
return $this->createSessionToken('pcsrf');
}
/**
* Check if the token exists for the current session (a token can be used only one time)
*
* @access public
* @param string $token CSRF token
* @return bool
*/
public function validateCSRFToken($token)
{
return $this->validateSessionToken('csrf', $token);
}
/**
* Check if the token exists as a reusable CSRF token
*
* @access public
* @param string $token CSRF token
* @return bool
*/
public function validateReusableCSRFToken($token)
{
return $this->validateSessionToken('pcsrf', $token);
}
/**
* Generate a session token of the given type
*
* @access protected
* @param string $type Token type
* @return string Random token
*/
protected function createSessionToken($type)
{
$nonce = self::getToken(self::$NONCE_LENGTH);
return $nonce . $this->signSessionToken($type, $nonce);
}
/**
* Check a session token of the given type
*
* @access protected
* @param string $type Token type
* @param string $token Session token
* @return bool
*/
protected function validateSessionToken($type, $token)
{
if (!is_string($token)) {
return false;
}
if (strlen($token) != (self::$NONCE_LENGTH + self::$HMAC_LENGTH) * 2) {
return false;
}
$nonce = substr($token, 0, self::$NONCE_LENGTH * 2);
$hmac = substr($token, self::$NONCE_LENGTH * 2, self::$HMAC_LENGTH * 2);
return hash_equals($this->signSessionToken($type, $nonce), $hmac);
}
/**
* Sign a nonce with the key belonging to the given type
*
* @access protected
* @param string $type Token type
* @param string $nonce Nonce to sign
* @return string
*/
protected function signSessionToken($type, $nonce)
{
if (!session_exists($type . '_key')) {
session_set($type . '_key', self::getToken(self::$KEY_LENGTH));
}
$data = $nonce . '-' . session_id();
$key = session_get($type . '_key');
$hmac = hash_hmac(self::$HMAC_ALGO, $data, $key, true);
return bin2hex(substr($hmac, 0, self::$HMAC_LENGTH));
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Kanboard\Core\Session;
use Kanboard\Core\Base;
/**
* Session Flash Message
*
* @package Kanboard\Core\Session
* @author Frederic Guillot
*/
class FlashMessage extends Base
{
/**
* Add success message
*
* @access public
* @param string $message
*/
public function success($message)
{
$this->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 (! session_exists('flash')) {
session_set('flash', []);
}
session_merge('flash', [$key => $message]);
}
/**
* Get flash message
*
* @access public
* @param string $key
* @return string
*/
public function getMessage($key)
{
$message = '';
if (session_exists('flash')) {
$messages = session_get('flash');
if (isset($messages[$key])) {
$message = $messages[$key];
unset($messages[$key]);
session_set('flash', $messages);
}
}
return $message;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Kanboard\Core\Session;
use PicoDb\Database;
use SessionHandlerInterface;
/**
* Class SessionHandler
*
* @package Kanboard\Core\Session
*/
class SessionHandler implements SessionHandlerInterface
{
const TABLE = 'sessions';
/**
* @var Database
*/
private $db;
public function __construct(Database $db)
{
$this->db = $db;
}
#[\ReturnTypeWillChange]
public function close()
{
return true;
}
#[\ReturnTypeWillChange]
public function destroy($sessionID)
{
return $this->db->table(self::TABLE)->eq('id', $sessionID)->remove();
}
#[\ReturnTypeWillChange]
public function gc($maxlifetime)
{
return $this->db->table(self::TABLE)->lt('expire_at', time())->remove();
}
#[\ReturnTypeWillChange]
public function open($savePath, $name)
{
return true;
}
#[\ReturnTypeWillChange]
public function read($sessionID)
{
$result = $this->db->table(self::TABLE)->eq('id', $sessionID)->gt('expire_at', time())->findOneColumn('data');
// Note: Returning false display an error message and write() is never called
// preventing new sessions to be created when calling session_start()
if (empty($result)) {
return '';
}
// Sanitize session data to prevent object deserialization attacks (CWE-502).
// Using allowed_classes: false converts any serialized objects to harmless
// __PHP_Incomplete_Class instances, preventing exploitation via gadget chains.
$sanitized = @unserialize($result, ['allowed_classes' => false]);
// unserialize() returns false both on failure AND when the data legitimately
// represents boolean false (serialized as 'b:0;'). Check the raw string to
// distinguish a real deserialization error from a valid false value.
if ($sanitized === false && $result !== 'b:0;') {
// Data could not be unserialized (e.g. legacy format after handler change);
// discard it so a fresh session is created.
return '';
}
return serialize($sanitized);
}
#[\ReturnTypeWillChange]
public function write($sessionID, $data)
{
if (SESSION_DURATION > 0) {
$lifetime = time() + SESSION_DURATION;
} else {
$lifetime = time() + (ini_get('session.gc_maxlifetime') ?: 1440);
}
$this->db->startTransaction();
if ($this->db->table(self::TABLE)->eq('id', $sessionID)->exists()) {
$this->db->table(self::TABLE)->eq('id', $sessionID)->update([
'expire_at' => $lifetime,
'data' => $data,
]);
} else {
$this->db->table(self::TABLE)->insert([
'id' => $sessionID,
'expire_at' => $lifetime,
'data' => $data,
]);
}
$this->db->closeTransaction();
return true;
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace Kanboard\Core\Session;
use Kanboard\Core\Base;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Session Manager
*
* @package Kanboard\Core\Session
* @author Frederic Guillot
*/
class SessionManager extends Base
{
/**
* Event names
*
* @var string
*/
const EVENT_DESTROY = 'session.destroy';
/**
* Return true if the session is open
*
* @static
* @access public
* @return boolean
*/
public static function isOpen()
{
return session_id() !== '';
}
/**
* Create a new session
*
* @access public
*/
public function open()
{
if (SESSION_HANDLER === 'db') {
session_set_save_handler(new SessionHandler($this->db), true);
}
$this->configure();
if (ini_get('session.auto_start') == 1) {
session_destroy();
}
session_name('KB_SID');
session_start();
}
/**
* Destroy the session
*
* @access public
*/
public function close()
{
$this->dispatcher->dispatch(new Event(), self::EVENT_DESTROY);
// 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,
$this->request->isHTTPS(),
true
);
// Use php_serialize handler so session data is a single serialize() call,
// which allows safe sanitization in SessionHandler::read() (CWE-502 mitigation).
ini_set('session.serialize_handler', 'php_serialize');
// 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', '1'); // 'sha512' is not compatible with FreeBSD, only MD5 '0' and SHA-1 '1' seems to work
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');
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace Kanboard\Core;
/**
* Template
*
* @package core
* @author Frederic Guillot
*
* @property \Kanboard\Helper\AppHelper $app
* @property \Kanboard\Helper\AssetHelper $asset
* @property \Kanboard\Helper\DateHelper $dt
* @property \Kanboard\Helper\FileHelper $file
* @property \Kanboard\Helper\FormHelper $form
* @property \Kanboard\Helper\HookHelper $hook
* @property \Kanboard\Helper\ModelHelper $model
* @property \Kanboard\Helper\SubtaskHelper $subtask
* @property \Kanboard\Helper\TaskHelper $task
* @property \Kanboard\Helper\TextHelper $text
* @property \Kanboard\Helper\UrlHelper $url
* @property \Kanboard\Helper\UserHelper $user
* @property \Kanboard\Helper\LayoutHelper $layout
* @property \Kanboard\Helper\ProjectHeaderHelper $projectHeader
*/
class Template
{
/**
* Helper object
*
* @access private
* @var Helper
*/
private $helper;
/**
* List of template overrides
*
* @access private
* @var array
*/
private $overrides = array();
/**
* Template constructor
*
* @access public
* @param Helper $helper
*/
public function __construct(Helper $helper)
{
$this->helper = $helper;
}
/**
* Expose helpers with magic getter
*
* @access public
* @param string $helper
* @return mixed
*/
public function __get($helper)
{
return $this->helper->getHelper($helper);
}
/**
* Render a template
*
* Example:
*
* $template->render('template_name', ['bla' => 'value']);
*
* @access public
* @param string $__template_name Template name
* @param array $__template_args Key/Value map of template variables
* @return string
*/
public function render($__template_name, array $__template_args = array())
{
extract($__template_args);
ob_start();
include $this->getTemplateFile($__template_name);
return ob_get_clean();
}
/**
* Define a new template override
*
* @access public
* @param string $original_template
* @param string $new_template
*/
public function setTemplateOverride($original_template, $new_template)
{
$this->overrides[$original_template] = $new_template;
}
/**
* Find template filename
*
* Core template: 'task/show' or 'kanboard:task/show'
* Plugin template: 'myplugin:task/show'
*
* @access public
* @param string $template
* @return string
*/
public function getTemplateFile($template)
{
$plugin = '';
$template = isset($this->overrides[$template]) ? $this->overrides[$template] : $template;
if (strpos($template, ':') !== false) {
list($plugin, $template) = explode(':', $template);
}
if ($plugin !== 'kanboard' && $plugin !== '') {
return implode(DIRECTORY_SEPARATOR, array(PLUGINS_DIR, ucfirst($plugin), 'Template', $template.'.php'));
}
return implode(DIRECTORY_SEPARATOR, array(__DIR__, '..', 'Template', $template.'.php'));
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace Kanboard\Core;
/**
* Thumbnail Generator
*
* @package core
* @author Frederic Guillot
*/
class Thumbnail
{
protected $compression = -1;
protected $metadata = array();
protected $srcImage;
protected $dstImage;
/**
* Create a thumbnail from a local file
*
* @static
* @access public
* @param string $filename
* @return Thumbnail
*/
public static function createFromFile($filename)
{
$self = new static();
$self->fromFile($filename);
return $self;
}
/**
* Create a thumbnail from a string
*
* @static
* @access public
* @param string $blob
* @return Thumbnail
*/
public static function createFromString($blob)
{
$self = new static();
$self->fromString($blob);
return $self;
}
/**
* Load the local image file in memory with GD
*
* @access public
* @param string $filename
* @return Thumbnail
*/
public function fromFile($filename)
{
$this->metadata = getimagesize($filename);
$this->srcImage = @imagecreatefromstring(file_get_contents($filename));
return $this;
}
/**
* Load the image blob in memory with GD
*
* @access public
* @param string $blob
* @return Thumbnail
*/
public function fromString($blob)
{
if (!function_exists('getimagesizefromstring')) {
$uri = 'data://application/octet-stream;base64,' . base64_encode($blob);
$this->metadata = getimagesize($uri);
} else {
$this->metadata = getimagesizefromstring($blob);
}
// Avoid warning from libpng when loading PNG image with obscure or incorrect iCCP profiles
$this->srcImage = @imagecreatefromstring($blob);
return $this;
}
/**
* Resize the image
*
* @access public
* @param int $width
* @param int $height
* @return Thumbnail
*/
public function resize($width = 250, $height = 100)
{
$srcWidth = $this->metadata[0];
$srcHeight = $this->metadata[1];
$dstX = 0;
$dstY = 0;
if ($width == 0 && $height == 0) {
$width = 100;
$height = 100;
}
if ($width > 0 && $height == 0) {
$dstWidth = $width;
$dstHeight = floor($srcHeight * ($width / $srcWidth));
$this->dstImage = imagecreatetruecolor($dstWidth, $dstHeight);
} elseif ($width == 0 && $height > 0) {
$dstWidth = floor($srcWidth * ($height / $srcHeight));
$dstHeight = $height;
$this->dstImage = imagecreatetruecolor($dstWidth, $dstHeight);
} else {
$srcRatio = $srcWidth / $srcHeight;
$resizeRatio = $width / $height;
if ($srcRatio <= $resizeRatio) {
$dstWidth = $width;
$dstHeight = floor($srcHeight * ($width / $srcWidth));
$dstY = ($dstHeight - $height) / 2 * (-1);
} else {
$dstWidth = floor($srcWidth * ($height / $srcHeight));
$dstHeight = $height;
$dstX = ($dstWidth - $width) / 2 * (-1);
}
$this->dstImage = imagecreatetruecolor($width, $height);
}
imagealphablending($this->dstImage, false);
imagesavealpha($this->dstImage, true);
imagecopyresampled($this->dstImage, $this->srcImage, (int) $dstX, (int) $dstY, 0, 0, (int) $dstWidth, (int) $dstHeight, (int) $srcWidth, (int) $srcHeight);
return $this;
}
/**
* Save the thumbnail to a local file
*
* @access public
* @param string $filename
* @return Thumbnail
*/
public function toFile($filename)
{
imagepng($this->dstImage, $filename, $this->compression);
imagedestroy($this->dstImage);
imagedestroy($this->srcImage);
return $this;
}
/**
* Return the thumbnail as a string
*
* @access public
* @return string
*/
public function toString()
{
ob_start();
imagepng($this->dstImage, null, $this->compression);
imagedestroy($this->dstImage);
imagedestroy($this->srcImage);
return ob_get_clean();
}
/**
* Output the thumbnail directly to the browser or stdout
*
* @access public
*/
public function toOutput()
{
imagepng($this->dstImage, null, $this->compression);
imagedestroy($this->dstImage);
imagedestroy($this->srcImage);
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Kanboard\Core;
use Pimple\Container;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* Tool class
*
* @package core
* @author Frederic Guillot
*/
class Tool
{
/**
* Remove recursively a directory
*
* @static
* @access public
* @param string $directory
* @param bool $removeDirectory
*/
public static function removeAllFiles($directory, $removeDirectory = true)
{
$it = new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
rmdir($file->getRealPath());
} else {
unlink($file->getRealPath());
}
}
if ($removeDirectory) {
rmdir($directory);
}
}
/**
* Build dependency injection containers from an array
*
* @static
* @access public
* @param Container $container
* @param array $namespaces
* @return Container
*/
public static function buildDIC(Container $container, array $namespaces)
{
foreach ($namespaces as $namespace => $classes) {
foreach ($classes as $name) {
$class = '\\Kanboard\\'.$namespace.'\\'.$name;
$container[lcfirst($name)] = function ($c) use ($class) {
return new $class($c);
};
}
}
return $container;
}
/**
* Build dependency injection container from an array
*
* @static
* @access public
* @param Container $container
* @param array $namespaces
* @return Container
*/
public static function buildFactories(Container $container, array $namespaces)
{
foreach ($namespaces as $namespace => $classes) {
foreach ($classes as $name) {
$class = '\\Kanboard\\'.$namespace.'\\'.$name;
$container[lcfirst($name)] = $container->factory(function ($c) use ($class) {
return new $class($c);
});
}
}
return $container;
}
/**
* Build dependency injection container for custom helpers from an array
*
* @static
* @access public
* @param Container $container
* @param array $namespaces
* @return Container
*/
public static function buildDICHelpers(Container $container, array $namespaces)
{
foreach ($namespaces as $namespace => $classes) {
foreach ($classes as $name) {
$class = '\\Kanboard\\'.$namespace.'\\'.$name;
$container['helper']->register($name, $class);
}
}
return $container;
}
}
+214
View File
@@ -0,0 +1,214 @@
<?php
namespace Kanboard\Core;
/**
* Translator class
*
* @package core
* @author Frederic Guillot
*/
class Translator
{
/**
* Locale
*
* @static
* @access public
* @var array
*/
public static $locales = array();
/**
* Instance
*
* @static
* @access private
* @var Translator
*/
private static $instance = null;
/**
* Get instance
*
* @static
* @access public
* @return Translator
*/
public static function getInstance()
{
if (self::$instance === null) {
self::$instance = new self;
}
return self::$instance;
}
/**
* Get a translation
*
* $translator->translate('I have %d kids', 5);
*
* @access public
* @param string $identifier Default string
* @return string
*/
public function translate($identifier)
{
$args = func_get_args();
array_shift($args);
array_unshift($args, $this->get($identifier, $identifier));
foreach ($args as &$arg) {
$arg = htmlspecialchars((string) $arg, ENT_QUOTES, 'UTF-8', false);
}
return call_user_func_array(
'sprintf',
$args
);
}
/**
* Get a translation with no HTML escaping
*
* $translator->translateNoEscaping('I have %d kids', 5);
*
* @access public
* @param string $identifier Default string
* @return string
*/
public function translateNoEscaping($identifier)
{
$args = func_get_args();
array_shift($args);
array_unshift($args, $this->get($identifier, $identifier));
return call_user_func_array(
'sprintf',
$args
);
}
/**
* Get a formatted number
*
* $translator->number(1234.56);
*
* @access public
* @param float $number Number to format
* @return string
*/
public function number($number)
{
return number_format(
$number,
$this->get('number.decimals', 2),
$this->get('number.decimals_separator', '.'),
$this->get('number.thousands_separator', ',')
);
}
/**
* Get a formatted currency number
*
* $translator->currency(1234.56);
*
* @access public
* @param float $amount Number to format
* @return string
*/
public function currency($amount)
{
$position = $this->get('currency.position', 'before');
$symbol = $this->get('currency.symbol', '$');
$str = '';
if ($position === 'before') {
$str .= $symbol;
}
$str .= $this->number($amount);
if ($position === 'after') {
$str .= ' '.$symbol;
}
return $str;
}
/**
* Get an identifier from the translations or return the default
*
* @access public
* @param string $identifier Locale identifier
* @param string $default Default value
* @return string
*/
public function get($identifier, $default = '')
{
if (isset(self::$locales[$identifier])) {
return self::$locales[$identifier];
} else {
return $default;
}
}
/**
* Load translations
*
* @static
* @access public
* @param string $languageCode Locale code: fr_FR
* @param string $localeDir Locale folder
* @return boolean True if the translation file has been loaded
*/
public static function load($languageCode, $localeDir = '')
{
if (! preg_match('/^[a-z]{2}(_[a-zA-Z]{2,4}(_[a-zA-Z]{2,4})?)?$/', $languageCode)) {
return false;
}
if ($localeDir === '') {
$localeDir = self::getDefaultFolder();
}
$baseDir = realpath($localeDir);
if ($baseDir === false) {
return false;
}
$realFilePath = realpath(implode(DIRECTORY_SEPARATOR, [$localeDir, $languageCode, 'translations.php']));
if ($realFilePath !== false && strpos($realFilePath, $baseDir) === 0) {
self::$locales = array_merge(self::$locales, require($realFilePath));
return true;
}
return false;
}
/**
* Clear locales stored in memory
*
* @static
* @access public
*/
public static function unload()
{
self::$locales = array();
}
/**
* Get default locales folder
*
* @access public
* @return string
*/
public static function getDefaultFolder()
{
return implode(DIRECTORY_SEPARATOR, array(__DIR__, '..', 'Locale'));
}
}

Some files were not shown because too many files have changed in this diff Show More