shell bypass 403
<?php
/**
* @package Joomla.Administrator
* @subpackage com_scheduler
*
* @copyright (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
namespace Joomla\Component\Scheduler\Administrator\Model;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Date\Date;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\CMS\Object\CMSObject;
use Joomla\Component\Scheduler\Administrator\Helper\SchedulerHelper;
use Joomla\Component\Scheduler\Administrator\Task\TaskOption;
use Joomla\Database\ParameterType;
use Joomla\Database\QueryInterface;
use Joomla\Utilities\ArrayHelper;
// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects
/**
* The MVC Model for TasksView.
* Defines methods to deal with operations concerning multiple `#__scheduler_tasks` entries.
*
* @since 4.1.0
*/
class TasksModel extends ListModel
{
protected $listForbiddenList = ['select', 'multi_ordering'];
/**
* Constructor.
*
* @param array $config An optional associative array of configuration settings.
* @param ?MVCFactoryInterface $factory The factory.
*
* @since 4.1.0
* @throws \Exception
* @see \JControllerLegacy
*/
public function __construct($config = [], ?MVCFactoryInterface $factory = null)
{
if (empty($config['filter_fields'])) {
$config['filter_fields'] = [
'id', 'a.id',
'asset_id', 'a.asset_id',
'title', 'a.title',
'type', 'a.type',
'type_title', 'j.type_title',
'state', 'a.state',
'last_exit_code', 'a.last_exit_code',
'last_execution', 'a.last_execution',
'next_execution', 'a.next_execution',
'times_executed', 'a.times_executed',
'times_failed', 'a.times_failed',
'ordering', 'a.ordering',
'priority', 'a.priority',
'note', 'a.note',
'created', 'a.created',
'created_by', 'a.created_by',
];
}
parent::__construct($config, $factory);
}
/**
* Method to get a store id based on model configuration state.
*
* This is necessary because the model is used by the component and
* different modules that might need different sets of data or different
* ordering requirements.
*
* @param string $id A prefix for the store id.
*
* @return string A store id.
*
* @since 4.1.0
*/
protected function getStoreId($id = ''): string
{
// Compile the store id.
$id .= ':' . $this->getState('filter.search');
$id .= ':' . $this->getState('filter.state');
$id .= ':' . $this->getState('filter.type');
$id .= ':' . $this->getState('filter.orphaned');
$id .= ':' . $this->getState('filter.due');
$id .= ':' . $this->getState('filter.locked');
$id .= ':' . $this->getState('filter.trigger');
$id .= ':' . $this->getState('list.select');
return parent::getStoreId($id);
}
/**
* Method to create a query for a list of items.
*
* @return QueryInterface
*
* @since 4.1.0
* @throws \Exception
*/
protected function getListQuery(): QueryInterface
{
// Create a new query object.
$db = $this->getDatabase();
$query = $db->getQuery(true);
/**
* Select the required fields from the table.
* ? Do we need all these defaults ?
* ? Does 'list.select' exist ?
*/
$query->select(
$this->getState(
'list.select',
[
$db->quoteName('a.id'),
$db->quoteName('a.asset_id'),
$db->quoteName('a.title'),
$db->quoteName('a.type'),
$db->quoteName('a.execution_rules'),
$db->quoteName('a.state'),
$db->quoteName('a.last_exit_code'),
$db->quoteName('a.locked'),
$db->quoteName('a.last_execution'),
$db->quoteName('a.next_execution'),
$db->quoteName('a.times_executed'),
$db->quoteName('a.times_failed'),
$db->quoteName('a.priority'),
$db->quoteName('a.ordering'),
$db->quoteName('a.note'),
$db->quoteName('a.created_by'),
$db->quoteName('a.checked_out'),
$db->quoteName('a.checked_out_time'),
]
)
)
->select(
[
$db->quoteName('uc.name', 'editor'),
]
)
->from($db->quoteName('#__scheduler_tasks', 'a'))
->join('LEFT', $db->quoteName('#__users', 'uc'), $db->quoteName('uc.id') . ' = ' . $db->quoteName('a.checked_out'));
// Filters go below
$filterCount = 0;
/**
* Extends query if already filtered.
*
* @param string $outerGlue
* @param array $conditions
* @param string $innerGlue
*
* @since 4.1.0
*/
$extendWhereIfFiltered = static function (
string $outerGlue,
array $conditions,
string $innerGlue
) use (
$query,
&$filterCount
) {
if ($filterCount++) {
$query->extendWhere($outerGlue, $conditions, $innerGlue);
} else {
$query->where($conditions, $innerGlue);
}
};
// Filter over ID, title (redundant to search, but) ---
if (is_numeric($id = $this->getState('filter.id'))) {
$filterCount++;
$id = (int) $id;
$query->where($db->quoteName('a.id') . ' = :id')
->bind(':id', $id, ParameterType::INTEGER);
} elseif ($title = $this->getState('filter.title')) {
$filterCount++;
$match = "%$title%";
$query->where($db->quoteName('a.title') . ' LIKE :match')
->bind(':match', $match);
}
// Filter orphaned (-1: exclude, 0: include, 1: only) ----
$filterOrphaned = (int) $this->getState('filter.orphaned');
if ($filterOrphaned !== 0) {
$filterCount++;
$taskOptions = SchedulerHelper::getTaskOptions();
// Array of all active routine ids
$activeRoutines = array_map(
static function (TaskOption $taskOption): string {
return $taskOption->id;
},
$taskOptions->options
);
if ($filterOrphaned === -1) {
$query->whereIn($db->quoteName('type'), $activeRoutines, ParameterType::STRING);
} else {
$query->whereNotIn($db->quoteName('type'), $activeRoutines, ParameterType::STRING);
}
}
// Filter over state ----
$state = $this->getState('filter.state');
if ($state !== '*') {
$filterCount++;
if (is_numeric($state)) {
$state = (int) $state;
$query->where($db->quoteName('a.state') . ' = :state')
->bind(':state', $state, ParameterType::INTEGER);
} else {
$query->whereIn($db->quoteName('a.state'), [0, 1]);
}
}
// Filter over type ----
$typeFilter = $this->getState('filter.type');
if ($typeFilter) {
$filterCount++;
$query->where($db->quotename('a.type') . '= :type')
->bind(':type', $typeFilter);
}
// Filter over exit code ----
$exitCode = $this->getState('filter.last_exit_code');
if (is_numeric($exitCode)) {
$filterCount++;
$exitCode = (int) $exitCode;
$query->where($db->quoteName('a.last_exit_code') . '= :last_exit_code')
->bind(':last_exit_code', $exitCode, ParameterType::INTEGER);
}
// Filter due (-1: exclude, 0: include, 1: only) ----
$due = $this->getState('filter.due');
if (is_numeric($due) && $due != 0) {
$now = Factory::getDate('now', 'GMT')->toSql();
$operator = $due == 1 ? ' <= ' : ' > ';
$filterCount++;
$query->where($db->quoteName('a.next_execution') . $operator . ':now')
->bind(':now', $now);
}
/*
* Filter locked ---
* Locks can be either hard locks or soft locks. Locks that have expired (exceeded the task timeout) are soft
* locks. Hard-locked tasks are assumed to be running. Soft-locked tasks are assumed to have suffered a fatal
* failure.
* {-2: exclude-all, -1: exclude-hard-locked, 0: include, 1: include-only-locked, 2: include-only-soft-locked}
*/
$locked = $this->getState('filter.locked');
if (is_numeric($locked) && $locked != 0) {
$now = Factory::getDate('now', 'GMT');
$timeout = ComponentHelper::getParams('com_scheduler')->get('timeout', 300);
$timeout = new \DateInterval(\sprintf('PT%dS', $timeout));
$timeoutThreshold = (clone $now)->sub($timeout)->toSql();
$now = $now->toSql();
switch ($locked) {
case -2:
$query->where($db->quoteName('a.locked') . 'IS NULL');
break;
case -1:
$extendWhereIfFiltered(
'AND',
[
$db->quoteName('a.locked') . ' IS NULL',
$db->quoteName('a.locked') . ' < :threshold',
],
'OR'
);
$query->bind(':threshold', $timeoutThreshold);
break;
case 1:
$query->where($db->quoteName('a.locked') . ' IS NOT NULL');
break;
case 2:
$query->where($db->quoteName('a.locked') . ' < :threshold')
->bind(':threshold', $timeoutThreshold);
}
}
// Filter over search string if set (title, type title, note, id) ----
$searchStr = $this->getState('filter.search');
if (!empty($searchStr)) {
// Allow search by ID
if (stripos($searchStr, 'id:') === 0) {
// Add array support [?]
$id = (int) substr($searchStr, 3);
$query->where($db->quoteName('a.id') . '= :id')
->bind(':id', $id, ParameterType::INTEGER);
} elseif (stripos($searchStr, 'type:') !== 0) {
// Search by type is handled exceptionally in _getList() [@todo: remove refs]
$searchStr = "%$searchStr%";
// Bind keys to query
$query->bind(':title', $searchStr)
->bind(':note', $searchStr);
$conditions = [
$db->quoteName('a.title') . ' LIKE :title',
$db->quoteName('a.note') . ' LIKE :note',
];
$extendWhereIfFiltered('AND', $conditions, 'OR');
}
}
// Add list ordering clause. ----
// @todo implement multi-column ordering someway
$multiOrdering = $this->state->get('list.multi_ordering');
if (!$multiOrdering || !\is_array($multiOrdering)) {
$orderCol = $this->state->get('list.ordering', 'a.next_execution');
$orderDir = $this->state->get('list.direction', 'asc');
// Type title ordering is handled exceptionally in _getList()
if ($orderCol !== 'j.type_title') {
$query->order($db->quoteName($orderCol) . ' ' . $orderDir);
// If ordering by type or state, also order by title.
if (\in_array($orderCol, ['a.type', 'a.state', 'a.priority'])) {
// @todo : Test if things are working as expected
$query->order($db->quoteName('a.title') . ' ' . $orderDir);
}
}
} else {
$orderClauses = [];
// Loop through provided clauses
foreach ($multiOrdering as $ordering) {
[$column, $direction] = explode(' ', $ordering);
$orderClauses[] = $db->quoteName($column) . ' ' . $direction;
}
// At least one correct order clause
if (\count($orderClauses) > 0) {
$query->order($orderClauses);
}
}
return $query;
}
/**
* Overloads the parent _getList() method.
* Takes care of attaching TaskOption objects and sorting by type titles.
*
* @param QueryInterface $query The database query to get the list with
* @param int $limitstart The list offset
* @param int $limit Number of list items to fetch
*
* @return object[]
*
* @since 4.1.0
* @throws \Exception
*/
protected function _getList($query, $limitstart = 0, $limit = 0): array
{
// Get stuff from the model state
$listOrder = $this->getState('list.ordering', 'a.next_execution');
$listDirectionN = strtolower($this->getState('list.direction', 'asc')) === 'desc' ? -1 : 1;
// Set limit parameters and get object list
$query->setLimit($limit, $limitstart);
$this->getDatabase()->setQuery($query);
// Return optionally an extended class.
// @todo: Use something other than CMSObject..
if ($this->getState('list.customClass')) {
$responseList = array_map(
static function (array $arr) {
$o = new CMSObject();
foreach ($arr as $k => $v) {
$o->{$k} = $v;
}
return $o;
},
$this->getDatabase()->loadAssocList() ?: []
);
} else {
$responseList = $this->getDatabase()->loadObjectList();
}
// Attach TaskOptions objects and a safe type title
$this->attachTaskOptions($responseList);
// If ordering by non-db fields, we need to sort here in code
if ($listOrder === 'j.type_title') {
$responseList = ArrayHelper::sortObjects($responseList, 'safeTypeTitle', $listDirectionN, true, false);
}
return $responseList;
}
/**
* For an array of items, attaches TaskOption objects and (safe) type titles to each.
*
* @param array $items Array of items, passed by reference
*
* @return void
*
* @since 4.1.0
* @throws \Exception
*/
private function attachTaskOptions(array $items): void
{
$taskOptions = SchedulerHelper::getTaskOptions();
foreach ($items as $item) {
$item->taskOption = $taskOptions->findOption($item->type);
$item->safeTypeTitle = $item->taskOption->title ?? Text::_('JGLOBAL_NONAPPLICABLE');
}
}
/**
* Proxy for the parent method.
* Sets ordering defaults.
*
* @param string $ordering Field to order/sort list by
* @param string $direction Direction in which to sort list
*
* @return void
* @since 4.1.0
*/
protected function populateState($ordering = 'a.next_execution', $direction = 'ASC'): void
{
$app = Factory::getApplication();
// Clean the multiorder values
if ($list = $app->getUserStateFromRequest($this->context . '.list', 'list', [], 'array')) {
if (!empty($list['multi_ordering']) && \is_array($list['multi_ordering'])) {
$orderClauses = [];
// Loop through provided clauses
foreach ($list['multi_ordering'] as $multiOrdering) {
// Split the combined string into individual variables
$multiOrderingParts = explode(' ', $multiOrdering, 2);
// Check that at least the column is present
if (\count($multiOrderingParts) < 1) {
continue;
}
// Assign variables
$multiOrderingColumn = $multiOrderingParts[0];
$multiOrderingDir = \count($multiOrderingParts) === 2 ? $multiOrderingParts[1] : 'asc';
// Validate provided column
if (!\in_array($multiOrderingColumn, $this->filter_fields)) {
continue;
}
// Validate order dir
if (strtolower($multiOrderingDir) !== 'asc' && strtolower($multiOrderingDir) !== 'desc') {
continue;
}
$orderClauses[] = $multiOrderingColumn . ' ' . $multiOrderingDir;
}
$this->setState('list.multi_ordering', $orderClauses);
}
}
// Call the parent method
parent::populateState($ordering, $direction);
}
/**
* Check if we have any enabled due tasks and no locked tasks.
*
* @param Date $time The next execution time to check against
*
* @return boolean
* @since 4.4.0
*/
public function hasDueTasks(Date $time): bool
{
$db = $this->getDatabase();
$now = $time->toSql();
$query = $db->getQuery(true)
// Count due tasks
->select('SUM(CASE WHEN ' . $db->quoteName('a.next_execution') . ' <= :now THEN 1 ELSE 0 END) AS due_count')
// Count locked tasks
->select('SUM(CASE WHEN ' . $db->quoteName('a.locked') . ' IS NULL THEN 0 ELSE 1 END) AS locked_count')
->from($db->quoteName('#__scheduler_tasks', 'a'))
->where($db->quoteName('a.state') . ' = 1')
->bind(':now', $now);
$db->setQuery($query);
$taskDetails = $db->loadObject();
// False if we don't have due tasks, or we have locked tasks
return $taskDetails && $taskDetails->due_count && !$taskDetails->locked_count;
}
/**
* Check if we have right now any enabled due tasks and no locked tasks.
*
* @return boolean
* @since 5.2.0
*/
public function getHasDueTasks()
{
return $this->hasDueTasks(Factory::getDate('now', 'UTC'));
}
}