diff --git a/administrator/components/com_actionlogs/config.xml b/administrator/components/com_actionlogs/config.xml index 03bf106f1c1c9..bfb6c100dca6d 100644 --- a/administrator/components/com_actionlogs/config.xml +++ b/administrator/components/com_actionlogs/config.xml @@ -28,7 +28,7 @@ type="logtype" label="COM_ACTIONLOGS_LOG_EXTENSIONS_LABEL" multiple="true" - default="com_banners,com_cache,com_categories,com_checkin,com_config,com_contact,com_content,com_installer,com_media,com_menus,com_messages,com_modules,com_newsfeeds,com_plugins,com_redirect,com_tags,com_templates,com_users" + default="com_banners,com_cache,com_categories,com_checkin,com_config,com_contact,com_content,com_installer,com_media,com_menus,com_messages,com_modules,com_newsfeeds,com_plugins,com_redirect,com_scheduler,com_tags,com_templates,com_users" /> + + + +
+ + + + + + + + +
+
+ + + + +
+
diff --git a/administrator/components/com_scheduler/config.xml b/administrator/components/com_scheduler/config.xml new file mode 100644 index 0000000000000..05bac35af1db5 --- /dev/null +++ b/administrator/components/com_scheduler/config.xml @@ -0,0 +1,123 @@ + + +
+ +
+
+ + + + + + + +
+
+ + + + + + + +
+
+ +
+
diff --git a/administrator/components/com_scheduler/forms/filter_tasks.xml b/administrator/components/com_scheduler/forms/filter_tasks.xml new file mode 100644 index 0000000000000..c374746c76a07 --- /dev/null +++ b/administrator/components/com_scheduler/forms/filter_tasks.xml @@ -0,0 +1,74 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/administrator/components/com_scheduler/forms/task.xml b/administrator/components/com_scheduler/forms/task.xml new file mode 100644 index 0000000000000..1121ec6483904 --- /dev/null +++ b/administrator/components/com_scheduler/forms/task.xml @@ -0,0 +1,275 @@ + +
+ + + +
+ + +
+ +
+ + + + + +
+ +
+ + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ +
+ + + + + + + +
+
+ +
+ + + + + +
+ + +
+ + + + + +
+
+
diff --git a/administrator/components/com_scheduler/layouts/form/field/webcron_link.php b/administrator/components/com_scheduler/layouts/form/field/webcron_link.php new file mode 100644 index 0000000000000..82535e109201f --- /dev/null +++ b/administrator/components/com_scheduler/layouts/form/field/webcron_link.php @@ -0,0 +1,56 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; + +extract($displayData); + +/** + * Layout variables + * ----------------- + * + * @var string $id DOM id of the field. + * @var string $label Label of the field. + * @var string $name Name of the input field. + * @var string $value Value attribute of the field. + */ + +Text::script('ERROR'); +Text::script('MESSAGE'); +Text::script('COM_SCHEDULER_CONFIG_WEBCRON_LINK_COPY_SUCCESS'); +Text::script('COM_SCHEDULER_CONFIG_WEBCRON_LINK_COPY_FAIL'); + +/** @var CMSApplication $app */ +$app = Factory::getApplication(); +$wa = $app->getDocument()->getWebAssetManager(); +$wa->getRegistry()->addExtensionRegistryFile('com_scheduler'); +$wa->useScript('com_scheduler.scheduler-config'); +?> + +
+ + +
+ diff --git a/administrator/components/com_scheduler/scheduler.xml b/administrator/components/com_scheduler/scheduler.xml new file mode 100644 index 0000000000000..5945633f1108d --- /dev/null +++ b/administrator/components/com_scheduler/scheduler.xml @@ -0,0 +1,30 @@ + + + COM_SCHEDULER + Joomla! Project + July 2021 + (C) 2021 Open Source Matters, Inc. + GNU General Public License version 2 or later; see LICENSE.txt + admin@joomla.org + www.joomla.org + 4.1.0 + COM_SCHEDULER_XML_DESCRIPTION + Joomla\Component\Scheduler + + js + css + + + access.xml + config.xml + scheduler.xml + forms + services + src + tmpl + + language/en-GB/com_scheduler.ini + language/en-GB/com_scheduler.sys.ini + + + diff --git a/administrator/components/com_scheduler/services/provider.php b/administrator/components/com_scheduler/services/provider.php new file mode 100644 index 0000000000000..dfb7f317a679e --- /dev/null +++ b/administrator/components/com_scheduler/services/provider.php @@ -0,0 +1,64 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Restrict direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Dispatcher\ComponentDispatcherFactoryInterface; +use Joomla\CMS\Extension\ComponentInterface; +use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory; +use Joomla\CMS\Extension\Service\Provider\MVCFactory; +use Joomla\CMS\HTML\Registry; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\Component\Scheduler\Administrator\Extension\SchedulerComponent; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; + +/** + * The com_scheduler service provider. + * Returns an instance of the Component's Service Provider Interface + * used to register the components initializers into a DI container + * created by the application. + * + * @since __DEPLOY_VERSION__ + */ +return new class implements ServiceProviderInterface +{ + /** + * Registers the service provider with a DI container. + * + * @param Container $container The DI container. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function register(Container $container) + { + /** + * Register the MVCFactory and ComponentDispatcherFactory providers to map + * 'MVCFactoryInterface' and 'ComponentDispatcherFactoryInterface' to their + * initializers and register them with the component's DI container. + */ + $container->registerServiceProvider(new MVCFactory('\\Joomla\\Component\\Scheduler')); + $container->registerServiceProvider(new ComponentDispatcherFactory('\\Joomla\\Component\\Scheduler')); + + $container->set( + ComponentInterface::class, + function (Container $container) { + $component = new SchedulerComponent($container->get(ComponentDispatcherFactoryInterface::class)); + + $component->setRegistry($container->get(Registry::class)); + $component->setMVCFactory($container->get(MVCFactoryInterface::class)); + + return $component; + } + ); + } +}; diff --git a/administrator/components/com_scheduler/src/Controller/DisplayController.php b/administrator/components/com_scheduler/src/Controller/DisplayController.php new file mode 100644 index 0000000000000..839f70c34f1d0 --- /dev/null +++ b/administrator/components/com_scheduler/src/Controller/DisplayController.php @@ -0,0 +1,108 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Scheduler\Administrator\Controller; + +// Restrict direct access +\defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Router\Route; + +/** + * Default controller for com_scheduler. + * + * @since __DEPLOY_VERSION__ + */ +class DisplayController extends BaseController +{ + /** + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $default_view = 'tasks'; + + /** + * @param boolean $cachable If true, the view output will be cached + * @param array $urlparams An array of safe url parameters and their variable types, for valid values see + * {@link InputFilter::clean()}. + * + * @return BaseController|boolean Returns either a BaseController object to support chaining, or false on failure + * + * @since __DEPLOY_VERSION__ + * @throws \Exception + */ + public function display($cachable = false, $urlparams = array()) + { + $layout = $this->input->get('layout', 'default'); + + // Check for edit form. + if ($layout === 'edit') + { + if (!$this->validateEntry()) + { + $tasksViewUrl = Route::_('index.php?option=com_scheduler&view=tasks', false); + $this->setRedirect($tasksViewUrl); + + return false; + } + } + + // Let the parent method take over + return parent::display($cachable, $urlparams); + } + + /** + * Validates entry to the view + * + * @param string $layout The layout to validate entry for (defaults to 'edit') + * + * @return boolean True is entry is valid + * + * @since __DEPLOY_VERSION__ + */ + private function validateEntry(string $layout = 'edit'): bool + { + $context = 'com_scheduler'; + $id = $this->input->getInt('id'); + $isValid = true; + + switch ($layout) + { + case 'edit': + + // True if controller was called and verified permissions + $inEditList = $this->checkEditId("$context.edit.task", $id); + $isNew = ($id == 0); + + // For new item, entry is invalid if task type was not selected through SelectView + if ($isNew && !$this->app->getUserState("$context.add.task.task_type")) + { + $this->setMessage((Text::_('COM_SCHEDULER_ERROR_FORBIDDEN_JUMP_TO_ADD_VIEW')), 'error'); + $isValid = false; + } + // For existing item, entry is invalid if TaskController has not granted access + elseif (!$inEditList) + { + if (!\count($this->app->getMessageQueue())) + { + $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_UNHELD_ID', $id), 'error'); + } + + $isValid = false; + } + break; + default: + break; + } + + return $isValid; + } +} diff --git a/administrator/components/com_scheduler/src/Controller/TaskController.php b/administrator/components/com_scheduler/src/Controller/TaskController.php new file mode 100644 index 0000000000000..e8206ec9692c0 --- /dev/null +++ b/administrator/components/com_scheduler/src/Controller/TaskController.php @@ -0,0 +1,118 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Scheduler\Administrator\Controller; + +// Restrict direct access +\defined('_JEXEC') or die; + +use Joomla\CMS\Application\AdministratorApplication; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\FormController; +use Joomla\CMS\Router\Route; +use Joomla\Component\Scheduler\Administrator\Helper\SchedulerHelper; + +/** + * MVC Controller for the item configuration page (TaskView). + * + * @since __DEPLOY_VERSION__ + */ +class TaskController extends FormController +{ + /** + * Add a new record + * + * @return boolean + * @since __DEPLOY_VERSION__ + * @throws \Exception + */ + public function add(): bool + { + /** @var AdministratorApplication $app */ + $app = $this->app; + $input = $app->getInput(); + $validTaskOptions = SchedulerHelper::getTaskOptions(); + + $canAdd = parent::add(); + + if ($canAdd !== true) + { + return false; + } + + $taskType = $input->get('type'); + $taskOption = $validTaskOptions->findOption($taskType) ?: null; + + if (!$taskOption) + { + // ? : Is this the right redirect [review] + $redirectUrl = 'index.php?option=' . $this->option . '&view=select&layout=edit'; + $this->setRedirect(Route::_($redirectUrl, false)); + $app->enqueueMessage(Text::_('COM_SCHEDULER_ERROR_INVALID_TASK_TYPE'), 'warning'); + $canAdd = false; + } + + $app->setUserState('com_scheduler.add.task.task_type', $taskType); + $app->setUserState('com_scheduler.add.task.task_option', $taskOption); + + // @todo : Parameter array handling below? + + return $canAdd; + } + + /** + * Override parent cancel method to reset the add task state + * + * @param ?string $key Primary key from the URL param + * + * @return boolean True if access level checks pass + * + * @since __DEPLOY_VERSION__ + */ + public function cancel($key = null): bool + { + $result = parent::cancel($key); + + $this->app->setUserState('com_scheduler.add.task.task_type', null); + $this->app->setUserState('com_scheduler.add.task.task_option', null); + + // ? Do we need to redirect based on URL's 'return' param? {@see ModuleController} + + return $result; + } + + /** + * Check if user has the authority to edit an asset + * + * @param array $data Array of input data + * @param string $key Name of key for primary key, defaults to 'id' + * + * @return boolean True if user is allowed to edit record + * + * @since __DEPLOY_VERSION__ + */ + protected function allowEdit($data = array(), $key = 'id'): bool + { + // Extract the recordId from $data, will come in handy + $recordId = (int) $data[$key] ?? 0; + + /** + * Zero record (id:0), return component edit permission by calling parent controller method + * ?: Is this the right way to do this? + */ + if ($recordId === 0) + { + return parent::allowEdit($data, $key); + } + + // @todo : Check if this works as expected + return $this->app->getIdentity()->authorise('core.edit', 'com_scheduler.task.' . $recordId); + + } +} diff --git a/administrator/components/com_scheduler/src/Controller/TasksController.php b/administrator/components/com_scheduler/src/Controller/TasksController.php new file mode 100644 index 0000000000000..5a4039f2e3372 --- /dev/null +++ b/administrator/components/com_scheduler/src/Controller/TasksController.php @@ -0,0 +1,111 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Scheduler\Administrator\Controller; + +// Restrict direct access +\defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\Controller\AdminController; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; +use Joomla\CMS\Router\Route; +use Joomla\Component\Scheduler\Administrator\Model\TaskModel; +use Joomla\Utilities\ArrayHelper; + +/** + * MVC Controller for TasksView. + * + * @since __DEPLOY_VERSION__ + */ +class TasksController extends AdminController +{ + /** + * Proxy for the parent method. + * + * @param string $name The name of the model. + * @param string $prefix The prefix for the PHP class name. + * @param array $config Array of configuration parameters. + * + * @return BaseDatabaseModel + * + * @since __DEPLOY_VERSION__ + */ + public function getModel($name = 'Task', $prefix = 'Administrator', $config = ['ignore_request' => true]): BaseDatabaseModel + { + return parent::getModel($name, $prefix, $config); + } + + /** + * Unlock a locked task, i.e., a task that is presumably still running but might have crashed and got stuck in the + * "locked" state. + * + * @return void + * + * @since __DEPLOY__VERSION__ + */ + public function unlock(): void + { + // Check for request forgeries + $this->checkToken(); + + /** @var integer[] $cid Items to publish (from request parameters). */ + $cid = $this->input->get('cid', [], 'array'); + + if (empty($cid)) + { + $this->app->getLogger() + ->warning(Text::_($this->text_prefix . '_NO_ITEM_SELECTED'), array('category' => 'jerror')); + } + else + { + /** @var TaskModel $model */ + $model = $this->getModel(); + + // Make sure the item IDs are integers + $cid = ArrayHelper::toInteger($cid); + + // Unlock the items. + try + { + $model->unlock($cid); + $errors = $model->getErrors(); + $noticeText = null; + + if ($errors) + { + Factory::getApplication() + ->enqueueMessage(Text::plural($this->text_prefix . '_N_ITEMS_FAILED_UNLOCKING', \count($cid)), 'error'); + } + else + { + $noticeText = $this->text_prefix . '_N_ITEMS_UNLOCKED'; + } + + if (\count($cid)) + { + $this->setMessage(Text::plural($noticeText, \count($cid))); + } + } + catch (\Exception $e) + { + $this->setMessage($e->getMessage(), 'error'); + } + } + + $this->setRedirect( + Route::_( + 'index.php?option=' . $this->option . '&view=' . $this->view_list + . $this->getRedirectToListAppend(), + false + ) + ); + } +} diff --git a/administrator/components/com_scheduler/src/Event/ExecuteTaskEvent.php b/administrator/components/com_scheduler/src/Event/ExecuteTaskEvent.php new file mode 100644 index 0000000000000..9a1635410cf27 --- /dev/null +++ b/administrator/components/com_scheduler/src/Event/ExecuteTaskEvent.php @@ -0,0 +1,97 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Scheduler\Administrator\Event; + +// Restrict direct access +\defined('_JEXEC') or die; + +use Joomla\CMS\Event\AbstractEvent; +use Joomla\Component\Scheduler\Administrator\Task\Task; + +/** + * Event class for onExecuteTask event. + * + * @since __DEPLOY_VERSION__ + */ +class ExecuteTaskEvent extends AbstractEvent +{ + /** + * Constructor. + * + * @param string $name The event name. + * @param array $arguments The event arguments. + * + * @since __DEPLOY_VERSION__ + * @throws \BadMethodCallException + */ + public function __construct($name, array $arguments = array()) + { + parent::__construct($name, $arguments); + + $arguments['resultSnapshot'] = null; + + if (!($arguments['subject'] ?? null) instanceof Task) + { + throw new \BadMethodCallException("The subject given for $name event must be an instance of " . Task::class); + } + + } + + /** + * Sets the task result snapshot and stops event propagation. + * + * @param array $snapshot The task snapshot. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function setResult(array $snapshot = []): void + { + $this->arguments['resultSnapshot'] = $snapshot; + + if (!empty($snapshot)) + { + $this->stopPropagation(); + } + } + + /** + * @return integer The task's taskId. + * + * @since __DEPLOY_VERSION__ + */ + public function getTaskId(): int + { + return $this->arguments['subject']->get('id'); + } + + /** + * @return string The task's 'type'. + * + * @since __DEPLOY_VERSION__ + */ + public function getRoutineId(): string + { + return $this->arguments['subject']->get('type'); + } + + /** + * Returns the snapshot of the triggered task if available, else an empty array + * + * @return array The task snapshot if available, else null + * + * @since __DEPLOY_VERSION__ + */ + public function getResultSnapshot(): array + { + return $this->arguments['resultSnapshot'] ?? []; + } +} diff --git a/administrator/components/com_scheduler/src/Extension/SchedulerComponent.php b/administrator/components/com_scheduler/src/Extension/SchedulerComponent.php new file mode 100644 index 0000000000000..64f03f1f53873 --- /dev/null +++ b/administrator/components/com_scheduler/src/Extension/SchedulerComponent.php @@ -0,0 +1,47 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Scheduler\Administrator\Extension; + +// Restrict direct access +\defined('_JEXEC') or die; + +use Joomla\CMS\Extension\BootableExtensionInterface; +use Joomla\CMS\Extension\MVCComponent; +use Joomla\CMS\HTML\HTMLRegistryAwareTrait; +use Psr\Container\ContainerInterface; + +/** + * Component class for com_scheduler. + * + * @since __DEPLOY_VERSION__ + * @todo Set up logger(s) here. + */ +class SchedulerComponent extends MVCComponent implements BootableExtensionInterface +{ + use HTMLRegistryAwareTrait; + + /** + * Booting the extension. This is the function to set up the environment of the extension like + * registering new class loaders, etc. + * + * If required, some initial set up can be done from services of the container, eg. + * registering HTML services. + * + * @param ContainerInterface $container The container + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function boot(ContainerInterface $container): void + { + // Pass + } +} diff --git a/administrator/components/com_scheduler/src/Field/CronField.php b/administrator/components/com_scheduler/src/Field/CronField.php new file mode 100644 index 0000000000000..8410b9695f026 --- /dev/null +++ b/administrator/components/com_scheduler/src/Field/CronField.php @@ -0,0 +1,200 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Scheduler\Administrator\Field; + +// Restrict direct access +\defined('_JEXEC') or die; + +use Joomla\CMS\Form\Field\ListField; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; + +/** + * Multi-select form field, supporting inputs of: + * minutes, hours, days of week, days of month and months. + * + * @since __DEPLOY_VERSION__ + */ +class CronField extends ListField +{ + /** + * The subtypes supported by this field type. + * + * @var string[] + * + * @since __DEPLOY_VERSION__ + */ + private const SUBTYPES = [ + 'minutes', + 'hours', + 'days_month', + 'months', + 'days_week', + ]; + + /** + * Count of predefined options for each subtype + * + * @var int[][] + * + * @since __DEPLOY_VERSION__ + */ + private const OPTIONS_RANGE = [ + 'minutes' => [0, 59], + 'hours' => [0, 23], + 'days_week' => [0, 6], + 'days_month' => [1, 31], + 'months' => [1, 12], + ]; + + /** + * Response labels for the 'month' and 'days_week' subtypes. + * The labels are language constants translated when needed. + * + * @var string[][] + * @since __DEPLOY_VERSION__ + */ + private const PREPARED_RESPONSE_LABELS = [ + 'months' => [ + 'JANUARY', 'FEBRUARY', 'MARCH', 'APRIL', 'MAY', 'JUNE', + 'JULY', 'AUGUST', 'SEPTEMBER', 'OCTOBER', 'NOVEMBER', 'DECEMBER', + ], + 'days_week' => [ + 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', + 'FRIDAY', 'SATURDAY', 'SUNDAY', + ], + ]; + + /** + * The form field type. + * + * @var string + * + * @since __DEPLOY_VERSION__ + */ + protected $type = 'cronIntervals'; + + /** + * The subtype of the CronIntervals field + * + * @var string + * @since __DEPLOY_VERSION__ + */ + private $subtype; + + /** + * If true, field options will include a wildcard + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + private $wildcard; + + /** + * If true, field will only have numeric labels (for days_week and months) + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + private $onlyNumericLabels; + + /** + * Override the parent method to set deal with subtypes. + * + * @param \SimpleXMLElement $element The SimpleXMLElement object representing the `` tag for the form + * field object. + * @param mixed $value The form field value to validate. + * @param string $group The field name group control value. This acts as an array container for + * the field. For example if the field has `name="foo"` and the group value is + * set to "bar" then the full field name would end up being "bar[foo]". + * + * @return boolean True on success. + * + * @since __DEPLOY_VERSION__ + */ + public function setup(\SimpleXMLElement $element, $value, $group = null): bool + { + $parentResult = parent::setup($element, $value, $group); + + $subtype = ((string) $element['subtype'] ?? '') ?: null; + $wildcard = ((string) $element['wildcard'] ?? '') === 'true'; + $onlyNumericLabels = ((string) $element['onlyNumericLabels']) === 'true'; + + if (!($subtype && \in_array($subtype, self::SUBTYPES))) + { + return false; + } + + $this->subtype = $subtype; + $this->wildcard = $wildcard; + $this->onlyNumericLabels = $onlyNumericLabels; + + return $parentResult; + } + + /** + * Method to get field options + * + * @return array Array of objects representing options in the options list + * + * @since __DEPLOY_VERSION__ + */ + protected function getOptions(): array + { + $subtype = $this->subtype; + $options = parent::getOptions(); + + if (!\in_array($subtype, self::SUBTYPES)) + { + return $options; + } + + if ($this->wildcard) + { + try + { + $options[] = HTMLHelper::_('select.option', '*', '*'); + } + catch (\InvalidArgumentException $e) + { + } + } + + [$optionLower, $optionUpper] = self::OPTIONS_RANGE[$subtype]; + + // If we need text labels, we translate them first + if (\array_key_exists($subtype, self::PREPARED_RESPONSE_LABELS) && !$this->onlyNumericLabels) + { + $labels = array_map( + static function (string $string): string { + return Text::_($string); + }, + self::PREPARED_RESPONSE_LABELS[$subtype] + ); + } + else + { + $labels = range(...self::OPTIONS_RANGE[$subtype]); + } + + for ([$i, $l] = [$optionLower, 0]; $i <= $optionUpper; $i++, $l++) + { + try + { + $options[] = HTMLHelper::_('select.option', (string) ($i), $labels[$l]); + } + catch (\InvalidArgumentException $e) + { + } + } + + return $options; + } +} diff --git a/administrator/components/com_scheduler/src/Field/ExecutionRuleField.php b/administrator/components/com_scheduler/src/Field/ExecutionRuleField.php new file mode 100644 index 0000000000000..2b11cdc6ad8d3 --- /dev/null +++ b/administrator/components/com_scheduler/src/Field/ExecutionRuleField.php @@ -0,0 +1,46 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Scheduler\Administrator\Field; + +// Restrict direct access +\defined('_JEXEC') or die; + +use Joomla\CMS\Form\Field\PredefinedlistField; + +/** + * A select list containing valid Cron interval types. + * + * @since __DEPLOY_VERSION__ + */ +class ExecutionRuleField extends PredefinedlistField +{ + /** + * The form field type. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $type = 'ExecutionRule'; + + /** + * Available execution rules. + * + * @var string[] + * @since __DEPLOY_VERSION__ + */ + protected $predefinedOptions = [ + 'interval-minutes' => 'COM_SCHEDULER_EXECUTION_INTERVAL_MINUTES', + 'interval-hours' => 'COM_SCHEDULER_EXECUTION_INTERVAL_HOURS', + 'interval-days' => 'COM_SCHEDULER_EXECUTION_INTERVAL_DAYS', + 'interval-months' => 'COM_SCHEDULER_EXECUTION_INTERVAL_MONTHS', + 'cron-expression' => 'COM_SCHEDULER_EXECUTION_CRON_EXPRESSION', + 'manual' => 'COM_SCHEDULER_OPTION_EXECUTION_MANUAL_LABEL', + ]; +} diff --git a/administrator/components/com_scheduler/src/Field/IntervalField.php b/administrator/components/com_scheduler/src/Field/IntervalField.php new file mode 100644 index 0000000000000..b5038cfacf2d6 --- /dev/null +++ b/administrator/components/com_scheduler/src/Field/IntervalField.php @@ -0,0 +1,98 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Scheduler\Administrator\Field; + +// Restrict direct access +\defined('_JEXEC') or die; + +use Joomla\CMS\Form\Field\NumberField; +use Joomla\CMS\Form\FormField; + +/** + * Select style field for interval(s) in minutes, hours, days and months. + * + * @since __DEPLOY_VERSION__ + */ +class IntervalField extends NumberField +{ + /** + * The form field type. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $type = 'Intervals'; + + /** + * The subtypes supported by this field type => [minVal, maxVal] + * + * @var string[] + * @since __DEPLOY_VERSION__ + */ + private const SUBTYPES = [ + 'minutes' => [1, 59], + 'hours' => [1, 23], + 'days' => [1, 30], + 'months' => [1, 12], + ]; + + /** + * The allowable maximum value of the field. + * + * @var float + * @since __DEPLOY_VERSION__ + */ + protected $max; + + /** + * The allowable minimum value of the field. + * + * @var float + * @since __DEPLOY_VERSION__ + */ + protected $min; + + /** + * The step by which value of the field increased or decreased. + * + * @var float + * @since __DEPLOY_VERSION__ + */ + protected $step = 1; + + /** + * Override the parent method to set deal with subtypes. + * + * @param \SimpleXMLElement $element The SimpleXMLElement object representing the `` tag for the form + * field object. + * @param mixed $value The form field value to validate. + * @param string $group The field name group control value. This acts as an array container for + * the field. For example if the field has `name="foo"` and the group value is + * set to "bar" then the full field name would end up being "bar[foo]". + * + * @return boolean True on success. + * + * @since __DEPLOY_VERSION__ + */ + public function setup(\SimpleXMLElement $element, $value, $group = null): bool + { + $parentResult = FormField::setup($element, $value, $group); + $subtype = ((string) $element['subtype'] ?? '') ?: null; + + if (empty($subtype) || !\array_key_exists($subtype, self::SUBTYPES)) + { + return false; + } + + [$this->min, $this->max] = self::SUBTYPES[$subtype]; + + return $parentResult; + } +} diff --git a/administrator/components/com_scheduler/src/Field/TaskStateField.php b/administrator/components/com_scheduler/src/Field/TaskStateField.php new file mode 100644 index 0000000000000..b800e629c5068 --- /dev/null +++ b/administrator/components/com_scheduler/src/Field/TaskStateField.php @@ -0,0 +1,44 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Scheduler\Administrator\Field; + +// Restrict direct access +\defined('_JEXEC') or die; + +use Joomla\CMS\Form\Field\PredefinedlistField; + +/** + * A predefined list field with all possible states for a com_scheduler entry. + * + * @since __DEPLOY_VERSION__ + */ +class TaskStateField extends PredefinedlistField +{ + /** + * The form field type. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + public $type = 'taskState'; + + /** + * Available states + * + * @var string[] + * @since __DEPLOY_VERSION__ + */ + protected $predefinedOptions = [ + -2 => 'JTRASHED', + 0 => 'JDISABLED', + 1 => 'JENABLED', + '*' => 'JALL', + ]; +} diff --git a/administrator/components/com_scheduler/src/Field/TaskTypeField.php b/administrator/components/com_scheduler/src/Field/TaskTypeField.php new file mode 100644 index 0000000000000..d39ffc242e0dc --- /dev/null +++ b/administrator/components/com_scheduler/src/Field/TaskTypeField.php @@ -0,0 +1,71 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Component\Scheduler\Administrator\Field; + +// Restrict direct access +\defined('_JEXEC') or die; + +use Joomla\CMS\Form\Field\ListField; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\Component\Scheduler\Administrator\Helper\SchedulerHelper; +use Joomla\Component\Scheduler\Administrator\Task\TaskOption; +use Joomla\Utilities\ArrayHelper; + +/** + * A list field with all available task routines. + * + * @since __DEPLOY_VERSION__ + */ +class TaskTypeField extends ListField +{ + /** + * The form field type. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected $type = 'taskType'; + + /** + * Method to get field options + * + * @return array + * + * @since __DEPLOY_VERSION__ + * @throws \Exception + */ + protected function getOptions(): array + { + $options = parent::getOptions(); + + // Get all available task types and sort by title + $types = ArrayHelper::sortObjects( + SchedulerHelper::getTaskOptions()->options, + 'title', + 1 + ); + + // Closure to add a TaskOption as a +
+ +
+ + + + + + +
+
+ +
+ + +
+

+ +

+ + +
+ + + items as $item) : ?> + + type; ?> + escape($item->title); ?> + escape(strip_tags($item->desc)), 200); ?> + + +
+

+

+ +

+
+ + + +
+ + +
+
+
diff --git a/administrator/components/com_scheduler/tmpl/select/modal.php b/administrator/components/com_scheduler/tmpl/select/modal.php new file mode 100644 index 0000000000000..745adeeef782e --- /dev/null +++ b/administrator/components/com_scheduler/tmpl/select/modal.php @@ -0,0 +1,36 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +/** The SelectView modal layout template. */ + +// Restrict direct access +defined('_JEXEC') or die; + +use Joomla\Component\Scheduler\Administrator\View\Select\HtmlView; + +/** @var HtmlView $this */ + +// Is this needed? +$this->modalLink = '&tmpl=component&view=select&layout=modal'; + +// Wrap the default layout in a div.container-popup +?> +
+ setLayout('default'); ?> + + loadTemplate(); + } + catch (Exception $e) + { + die('Exception while loading template..'); + } + ?> +
diff --git a/administrator/components/com_scheduler/tmpl/task/edit.php b/administrator/components/com_scheduler/tmpl/task/edit.php new file mode 100644 index 0000000000000..87aab93f00e94 --- /dev/null +++ b/administrator/components/com_scheduler/tmpl/task/edit.php @@ -0,0 +1,183 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Restrict direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Application\AdministratorApplication; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; +use Joomla\Component\Scheduler\Administrator\Task\TaskOption; +use Joomla\Component\Scheduler\Administrator\View\Task\HtmlView; + +/** @var HtmlView $this */ + +$wa = $this->document->getWebAssetManager(); + +$wa->useScript('keepalive'); +$wa->useScript('form.validate'); +$wa->useStyle('com_scheduler.admin-view-task-css'); + +/** @var AdministratorApplication $app */ +$app = $this->app; + +$input = $app->getInput(); + +// Fieldsets to be ignored by the `joomla.edit.params` template. +$this->ignore_fieldsets = ['aside', 'details', 'exec_hist', 'custom-cron-rules', 'basic', 'advanced', 'priority', 'task-params']; + +// Used by the `joomla.edit.params` template to render the right template for UI tabs. +$this->useCoreUI = true; + +$advancedFieldsets = $this->form->getFieldsets('params'); + +// Don't show the params fieldset, they will be loaded later +foreach ($advancedFieldsets as $fieldset) : + if (!empty($fieldset->showFront) || $fieldset->name === 'task_params') : + continue; + endif; + + $this->ignore_fieldsets[] = $fieldset->name; +endforeach; + +// ? : Are these of use here? +$isModal = $input->get('layout') === 'modal'; +$layout = $isModal ? 'modal' : 'edit'; +$tmpl = $isModal || $input->get('tmpl', '') === 'component' ? '&tmpl=component' : ''; +?> + +
+ + + + + +
+ 'general')); ?> + + + item->id) ? Text::_('COM_SCHEDULER_NEW_TASK') : Text::_('COM_SCHEDULER_EDIT_TASK') + ); + ?> +
+
+ + item->taskOption): + /** @var TaskOption $taskOption */ + $taskOption = $this->item->taskOption; ?> +
+

+ title ?> +

+

+ escape(strip_tags($taskOption->desc)), 250); + echo $desc; + ?> +

+
+ + enqueueMessage(Text::_('COM_SCHEDULER_WARNING_EXISTING_TASK_TYPE_NOT_FOUND'), 'warning'); + ?> + +
+ + form->renderFieldset('basic'); ?> +
+ +
+ + form->renderFieldset('custom-cron-rules'); ?> +
+ + +
+ +
+ form->renderFieldset('aside'); ?> +
+
+ + + + +
+
+
+ + form->renderFieldset('priority') ?> +
+ + showFront)) : + continue; + endif; ?> +
+ label ?: 'COM_SCHEDULER_FIELDSET_' . $fieldset->name) ?> + form->renderFieldset($fieldset->name) ?> +
+ +
+
+ + + + +
+
+
+ + form->renderFieldset('exec_hist'); ?> +
+
+
+ + + + +
+
+
+ + form->renderFieldset('details'); ?> +
+
+
+ + + + canDo->get('core.admin')) : ?> + +
+ +
+ form->getInput('rules'); ?> +
+
+ + + + form->getInput('context'); ?> + + +
+
diff --git a/administrator/components/com_scheduler/tmpl/tasks/default.php b/administrator/components/com_scheduler/tmpl/tasks/default.php new file mode 100644 index 0000000000000..ee84718f854e3 --- /dev/null +++ b/administrator/components/com_scheduler/tmpl/tasks/default.php @@ -0,0 +1,260 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Restrict direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; +use Joomla\Component\Scheduler\Administrator\View\Tasks\HtmlView; + +/** @var HtmlView $this*/ + +HTMLHelper::_('behavior.multiselect'); + +Text::script('COM_SCHEDULER_TEST_RUN_TITLE'); +Text::script('COM_SCHEDULER_TEST_RUN_TASK'); +Text::script('COM_SCHEDULER_TEST_RUN_DURATION'); +Text::script('COM_SCHEDULER_TEST_RUN_OUTPUT'); +Text::script('COM_SCHEDULER_TEST_RUN_STATUS_STARTED'); +Text::script('COM_SCHEDULER_TEST_RUN_STATUS_COMPLETED'); +Text::script('COM_SCHEDULER_TEST_RUN_STATUS_TERMINATED'); +Text::script('JLIB_JS_AJAX_ERROR_OTHER'); +Text::script('JLIB_JS_AJAX_ERROR_CONNECTION_ABORT'); +Text::script('JLIB_JS_AJAX_ERROR_TIMEOUT'); +Text::script('JLIB_JS_AJAX_ERROR_NO_CONTENT'); +Text::script('JLIB_JS_AJAX_ERROR_PARSE'); + +try +{ + $app = Factory::getApplication(); +} catch (Exception $e) +{ + die('Failed to get app'); +} + +$user = $app->getIdentity(); +$userId = $user->get('id'); +$listOrder = $this->escape($this->state->get('list.ordering')); +$listDirn = $this->escape($this->state->get('list.direction')); +$saveOrder = $listOrder == 'a.ordering'; +$section = null; +$mode = false; + +if ($saveOrder && !empty($this->items)) +{ + $saveOrderingUrl = 'index.php?option=com_scheduler&task=tasks.saveOrderAjax&tmpl=component&' . Session::getFormToken() . '=1'; + HTMLHelper::_('draggablelist.draggable'); +} + +$app->getDocument()->getWebAssetManager()->useScript('com_scheduler.test-task'); +?> + +
+
+ $this)); + ?> + + + items)): ?> + +
+ + +
+ + + + items)): ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + class="js-draggable" data-url="" data-direction="" data-nested="true" > + items as $i => $item): + $canCreate = $user->authorise('core.create', 'com_scheduler'); + $canEdit = $user->authorise('core.edit', 'com_scheduler'); + $canChange = $user->authorise('core.edit.state', 'com_scheduler'); + ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ , + , + +
+ + + + + + + + + + + + + + + + +
+ id, false, 'cid', 'cb', $item->title); ?> + + + + + + + + + + + + state, $i, 'tasks.', $canChange); ?> + + locked) : ?> + $canChange, 'prefix' => 'tasks.', + 'active_class' => 'none fa fa-running border-dark text-body', + 'inactive_class' => 'none fa fa-running', 'tip' => true, 'translate' => false, + 'active_title' => Text::sprintf('COM_SCHEDULER_RUNNING_SINCE', HTMLHelper::_('date', $item->last_execution, 'DATE_FORMAT_LC5')), + 'inactive_title' => Text::sprintf('COM_SCHEDULER_RUNNING_SINCE', HTMLHelper::_('date', $item->last_execution, 'DATE_FORMAT_LC5')), + ]); ?> + + + escape($item->title); ?> + + escape($item->title); ?> + + + + note)): ?> + + + escape($item->note)); ?> + + + + escape($item->safeTypeTitle); ?> + + last_execution ? HTMLHelper::_('date', $item->last_execution, 'DATE_FORMAT_LC5') : '-'; ?> + + + + id; ?> +
+ + pagination->getListFooter(); + + // Modal for test runs + $modalparams = [ + 'title' => '', + ]; + + $modalbody = '
'; + + echo HTMLHelper::_('bootstrap.renderModal', 'scheduler-test-modal', $modalparams, $modalbody); + + ?> + + + + + + +
+
diff --git a/administrator/components/com_scheduler/tmpl/tasks/default.xml b/administrator/components/com_scheduler/tmpl/tasks/default.xml new file mode 100644 index 0000000000000..2a93aaf81afb6 --- /dev/null +++ b/administrator/components/com_scheduler/tmpl/tasks/default.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/administrator/components/com_scheduler/tmpl/tasks/empty_state.php b/administrator/components/com_scheduler/tmpl/tasks/empty_state.php new file mode 100644 index 0000000000000..dc155208aaaf7 --- /dev/null +++ b/administrator/components/com_scheduler/tmpl/tasks/empty_state.php @@ -0,0 +1,27 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Layout\LayoutHelper; + +$displayData = [ + 'textPrefix' => 'COM_SCHEDULER', + 'formURL' => 'index.php?option=com_scheduler&task=task.add', + 'helpURL' => 'https://github.com/joomla-projects/soc21_website-cronjob', + 'icon' => 'icon-clock clock', +]; + +if (Factory::getApplication()->getIdentity()->authorise('core.create', 'com_scheduler')) +{ + $displayData['createURL'] = 'index.php?option=com_scheduler&view=select&layout=default'; +} + +echo LayoutHelper::render('joomla.content.emptystate', $displayData); diff --git a/administrator/language/en-GB/com_scheduler.ini b/administrator/language/en-GB/com_scheduler.ini new file mode 100644 index 0000000000000..1fd19a58063fb --- /dev/null +++ b/administrator/language/en-GB/com_scheduler.ini @@ -0,0 +1,152 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +COM_SCHEDULER="Scheduled Tasks" +COM_SCHEDULER_CONFIGURATION="Scheduled Tasks Manager Configuration" +COM_SCHEDULER_CONFIG_FIELDSET_LAZY_SCHEDULER_DESC="Configure how site visits trigger Scheduled Tasks." +COM_SCHEDULER_CONFIG_FIELDSET_LAZY_SCHEDULER_LABEL="Lazy Scheduler" +COM_SCHEDULER_CONFIG_GENERATE_WEBCRON_KEY_DESC="The webcron needs a protection key before it is functional. Saving this configuration will autogenerate the key." +COM_SCHEDULER_CONFIG_GLOBAL_WEBCRON_KEY_LABEL="Global Key" +COM_SCHEDULER_CONFIG_GLOBAL_WEBCRON_LINK_DESC="By default, requesting this base link will only run tasks due for execution. To execute a specific task, use the task's ID as a query parameter appended to the URL: BASE_URL&id=<task's id>" +COM_SCHEDULER_CONFIG_GLOBAL_WEBCRON_LINK_LABEL="Webcron Link (Base)" +COM_SCHEDULER_CONFIG_HASH_PROTECTION_DESC="If enabled, tasks will only be triggered when URLs have the scheduler hash as a parameter." +COM_SCHEDULER_CONFIG_LAZY_SCHEDULER_ENABLED_DESC="If disabled, scheduled tasks will not be triggered by visitors on the site.
Recommended if triggering with native cron." +COM_SCHEDULER_CONFIG_LAZY_SCHEDULER_ENABLED_LABEL="Lazy Scheduler" +COM_SCHEDULER_CONFIG_LAZY_SCHEDULER_INTERVAL_DESC="Interval between scheduler trigger requests from the client." +COM_SCHEDULER_CONFIG_LAZY_SCHEDULER_INTERVAL_LABEL="Request Interval" +COM_SCHEDULER_CONFIG_RESET_WEBCRON_KEY_LABEL="Reset Access Key" +COM_SCHEDULER_CONFIG_TASKS_FIELDSET_LABEL="Configure Tasks" +COM_SCHEDULER_CONFIG_TASK_TIMEOUT_LABEL="Task Timeout (seconds)" +COM_SCHEDULER_CONFIG_WEBCRON_DESC="Trigger and manage task execution with an external service." +COM_SCHEDULER_CONFIG_WEBCRON_ENABLED_LABEL="Web Cron" +COM_SCHEDULER_CONFIG_WEBCRON_LABEL="Web Cron" +COM_SCHEDULER_CONFIG_WEBCRON_LINK_COPY_DESC="Copy the link to your clipboard." +COM_SCHEDULER_CONFIG_WEBCRON_LINK_COPY_FAIL="Could not copy link!" +COM_SCHEDULER_CONFIG_WEBCRON_LINK_COPY_SUCCESS="Link copied!" +COM_SCHEDULER_DASHBOARD_TITLE="Scheduled Tasks Manager" +COM_SCHEDULER_DESCRIPTION_TASK_PRIORITY="This is an advanced option. Higher priority tasks can potentially block lower priority tasks." +COM_SCHEDULER_EDIT_TASK="Edit Task" +COM_SCHEDULER_EMPTYSTATE_BUTTON_ADD="Add a Task!" +COM_SCHEDULER_EMPTYSTATE_CONTENT="No Tasks!" +COM_SCHEDULER_EMPTYSTATE_TITLE="No Tasks have been created yet!" +COM_SCHEDULER_ERROR_FORBIDDEN_JUMP_TO_ADD_VIEW="You need to select a Task type first!" +COM_SCHEDULER_ERROR_INVALID_TASK_TYPE="Invalid Task Type!" +COM_SCHEDULER_EXECUTION_CRON_EXPRESSION="Cron Expression (Advanced)" +COM_SCHEDULER_EXECUTION_INTERVAL_DAYS="Interval, Days" +COM_SCHEDULER_EXECUTION_INTERVAL_HOURS="Interval, Hours" +COM_SCHEDULER_EXECUTION_INTERVAL_MINUTES="Interval, Minutes" +COM_SCHEDULER_EXECUTION_INTERVAL_MONTHS="Interval, Months" +COM_SCHEDULER_FIELDSET_BASIC="Basic Fields" +COM_SCHEDULER_FIELDSET_CRON_OPTIONS="Cron Match" +COM_SCHEDULER_FIELDSET_EXEC_HIST="Execution History" +COM_SCHEDULER_FIELDSET_LOGGING="Logging" +COM_SCHEDULER_FIELDSET_NOTIFICATIONS="Notifications" +COM_SCHEDULER_FIELDSET_PRIORITY="Priority" +COM_SCHEDULER_FIELDSET_TASK_PARAMS="Task Parameters" +COM_SCHEDULER_FIELD_HINT_LOG_FILE_AUTO="defaults to 'task_.log.php'" +COM_SCHEDULER_FIELD_LABEL_EXEC_RULE="Execution Rule" +COM_SCHEDULER_FIELD_LABEL_INDIVIDUAL_LOG="Individual Task Logs" +COM_SCHEDULER_FIELD_LABEL_INTERVAL_DAYS="Interval in Days" +COM_SCHEDULER_FIELD_LABEL_INTERVAL_HOURS="Interval in Hours" +COM_SCHEDULER_FIELD_LABEL_INTERVAL_MINUTES="Interval in Minutes" +COM_SCHEDULER_FIELD_LABEL_INTERVAL_MONTHS="Interval in Months" +COM_SCHEDULER_FIELD_LABEL_LOG_FILE="Log Filename" +COM_SCHEDULER_FIELD_LABEL_SHOW_ORPHANED="Show Orphaned" +COM_SCHEDULER_FIELD_OPTION_INTERVAL_MATCH_DAYS_M="Days of Month" +COM_SCHEDULER_FIELD_OPTION_INTERVAL_MATCH_DAYS_W="Days of Week" +COM_SCHEDULER_FIELD_OPTION_INTERVAL_MATCH_HOURS="Hours" +COM_SCHEDULER_FIELD_OPTION_INTERVAL_MATCH_MINUTES="Minutes" +COM_SCHEDULER_FIELD_OPTION_INTERVAL_MATCH_MONTHS="Months" +COM_SCHEDULER_FIELD_TASK_TYPE="Type ID" +COM_SCHEDULER_FILTER_SEARCH_DESC="Search in Task title and note. Prefix with 'ID:' to search for a task ID" +COM_SCHEDULER_FILTER_SEARCH_LABEL="Search Tasks" +COM_SCHEDULER_FORM_TITLE_EDIT="Edit Task" +COM_SCHEDULER_FORM_TITLE_NEW="New Task" +COM_SCHEDULER_HEADING_TASK_TYPE="- Task Type -" +COM_SCHEDULER_LABEL_EXEC_DAY="Execution Day" +COM_SCHEDULER_LABEL_EXEC_INTERVAL="Execution Interval" +COM_SCHEDULER_LABEL_EXEC_TIME="Execution Time (UTC)" +COM_SCHEDULER_LABEL_EXIT_CODE="Last Exit Code" +COM_SCHEDULER_LABEL_HOURS="Hours" +COM_SCHEDULER_LABEL_LAST_EXEC="Last Executed" +COM_SCHEDULER_LABEL_MINUTES="Minutes" +COM_SCHEDULER_LABEL_NEXT_EXEC="Next Execution" +COM_SCHEDULER_LABEL_NOTES="Note" +COM_SCHEDULER_LABEL_TASK_PRIORITY="Priority" +COM_SCHEDULER_LABEL_TASK_PRIORITY_HIGH="High" +COM_SCHEDULER_LABEL_TASK_PRIORITY_LOW="Low" +COM_SCHEDULER_LABEL_TASK_PRIORITY_NORMAL="Normal" +COM_SCHEDULER_LABEL_TIMES_EXEC="Times Executed" +COM_SCHEDULER_LABEL_TIMES_FAIL="Times Failed" +COM_SCHEDULER_LAST_RUN_DATE="Last Run Date" +COM_SCHEDULER_MANAGER_TASK="Task Manager" +COM_SCHEDULER_MANAGER_TASKS="Tasks Manager" +COM_SCHEDULER_MANAGER_TASK_EDIT="Edit Task" +COM_SCHEDULER_MANAGER_TASK_NEW="New Task" +COM_SCHEDULER_MSG_MANAGE_NO_TASK_PLUGINS="There are no task types matching your query!" +COM_SCHEDULER_NEW_TASK="New Task" +COM_SCHEDULER_NO_NOTE="" +COM_SCHEDULER_N_ITEMS_DELETED="%s tasks deleted." +COM_SCHEDULER_N_ITEMS_DELETED_1="Task deleted." +COM_SCHEDULER_N_ITEMS_FAILED_UNLOCKING="Failed to unlock %s tasks" +COM_SCHEDULER_N_ITEMS_FAILED_UNLOCKING_1="Failed to unlock one tasks" +COM_SCHEDULER_N_ITEMS_PUBLISHED="%s tasks enabled." +COM_SCHEDULER_N_ITEMS_PUBLISHED_1="Task enabled." +COM_SCHEDULER_N_ITEMS_TRASHED="%s tasks trashed." +COM_SCHEDULER_N_ITEMS_TRASHED_1="Task trashed." +COM_SCHEDULER_N_ITEMS_UNPUBLISHED="%s tasks disabled." +COM_SCHEDULER_N_ITEMS_UNPUBLISHED_1="Task disabled." +COM_SCHEDULER_N_ITEMS_UNLOCKED="%s tasks unlocked." +COM_SCHEDULER_N_ITEMS_UNLOCKED_1="Task unlocked." +COM_SCHEDULER_OPTION_EXECUTION_MANUAL_LABEL="Manual Execution" +COM_SCHEDULER_OPTION_ORPHANED_HIDE="Hide Orphaned" +COM_SCHEDULER_OPTION_ORPHANED_ONLY="Only Orphaned" +COM_SCHEDULER_OPTION_ORPHANED_SHOW="Show Orphaned" +COM_SCHEDULER_PERMISSION_TESTRUN="Test task" +COM_SCHEDULER_ROUTINE_LOG_PREFIX="Task> " +COM_SCHEDULER_RUNNING_SINCE="Running since %s" +COM_SCHEDULER_SCHEDULER="Scheduler" +COM_SCHEDULER_SCHEDULER_TASK_COMPLETE="Successfully finished task#%1$02d in %2$.2f (net %3$.2f) seconds." +COM_SCHEDULER_SCHEDULER_TASK_LOCKED="task#%1$02d is locked." +COM_SCHEDULER_SCHEDULER_TASK_ROUTINE_NA="Task#%1$02d has no corresponding plugin routine. The plugin might have been disabled or removed. Skipping execution." +COM_SCHEDULER_SCHEDULER_TASK_START="Running task#%1$02d '%2$s'." +COM_SCHEDULER_SCHEDULER_TASK_UNKNOWN_EXIT="Task#%1$02d exited with code %4$d in %2$.2f (net %3$.2f) seconds." +; Maybe not this +COM_SCHEDULER_SCHEDULER_TASK_UNLOCKED="Task#%1$02d was unlocked." +COM_SCHEDULER_SELECT_EXEC_RULE="--- Select Rule ---" +COM_SCHEDULER_SELECT_INTERVAL_DAYS="-- Select Interval in Days --" +COM_SCHEDULER_SELECT_INTERVAL_HOURS="-- Select Interval in Hours --" +COM_SCHEDULER_SELECT_INTERVAL_MINUTES="-- Select Interval in Minutes --" +COM_SCHEDULER_SELECT_INTERVAL_MONTHS="-- Select Interval in Months --" +COM_SCHEDULER_SELECT_TASK_TYPE="Select task, %s" +COM_SCHEDULER_SELECT_TYPE="- Task Type -" +COM_SCHEDULER_TABLE_CAPTION="Tasks List" +COM_SCHEDULER_TASK="Task" +COM_SCHEDULER_TASKS_VIEW_DEFAULT_DESC="Schedule and Manage Task Routines." +COM_SCHEDULER_TASKS_VIEW_DEFAULT_TITLE="Scheduled Tasks Manager" +COM_SCHEDULER_TASK_PARAMS_FIELDSET_LABEL="Task Parameters" +COM_SCHEDULER_TASK_PRIORITY_ASC="Task Priority, Ascending" +COM_SCHEDULER_TASK_PRIORITY_DESC="Task Priority, Descending" +COM_SCHEDULER_TASK_ROUTINE_EXCEPTION="Routine threw exception: %1$s" +COM_SCHEDULER_TASK_TYPE="Task Type" +COM_SCHEDULER_TASK_TYPE_ASC="Task Type Ascending" +COM_SCHEDULER_TASK_TYPE_DESC="Task Type Descending" +COM_SCHEDULER_TEST_RUN="Run Test" +COM_SCHEDULER_TEST_RUN_DURATION="Duration: %s seconds" +COM_SCHEDULER_TEST_RUN_OUTPUT="Output:
%s" +COM_SCHEDULER_TEST_RUN_STATUS_COMPLETED="Status: Completed" +COM_SCHEDULER_TEST_RUN_STATUS_STARTED="Status: Started" +COM_SCHEDULER_TEST_RUN_STATUS_TERMINATED="Status: Terminated" +COM_SCHEDULER_TEST_RUN_TASK="Task: \"%s\"" +COM_SCHEDULER_TEST_RUN_TITLE="Test task (ID: %d)" +COM_SCHEDULER_TEST_TASK="Test task" +COM_SCHEDULER_TRIGGER_CRON="Cron" +COM_SCHEDULER_TRIGGER_PSEUDOCRON="Pseudocron" +COM_SCHEDULER_TRIGGER_XVISITS="X-Visits" +COM_SCHEDULER_TOOLBAR_UNLOCK="Unlock" +COM_SCHEDULER_TYPE_CHOOSE="Select a Task type" +COM_SCHEDULER_TYPE_OR_SELECT_OPTIONS="Type or select options" +COM_SCHEDULER_WARNING_EXISTING_TASK_TYPE_NOT_FOUND="The task routine for this task could not be found!
It's likely that the provider plugin was removed or disabled." +COM_SCHEDULER_XML_DESCRIPTION="Component for managing scheduled tasks and cronjobs (if supported by the server)." diff --git a/administrator/language/en-GB/com_scheduler.sys.ini b/administrator/language/en-GB/com_scheduler.sys.ini new file mode 100644 index 0000000000000..7f17b0054d015 --- /dev/null +++ b/administrator/language/en-GB/com_scheduler.sys.ini @@ -0,0 +1,14 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +COM_SCHEDULER="Scheduled Tasks" +COM_SCHEDULER_ERROR_FORBIDDEN_JUMP_TO_ADD_VIEW="You need to select a Task type first!" +COM_SCHEDULER_ERROR_INVALID_TASK_TYPE="Invalid Task Type!" +COM_SCHEDULER_MANAGER_TASK="Task Manager" +COM_SCHEDULER_MANAGER_TASK_EDIT="Edit Task" +COM_SCHEDULER_MANAGER_TASK_NEW="New Task" +COM_SCHEDULER_MANAGER_TASKS="Tasks Manager" +COM_SCHEDULER_MSG_MANAGE_NO_TASK_PLUGINS="There are no task types matching your query!" +COM_SCHEDULER_XML_DESCRIPTION="Component for managing scheduled tasks and cronjobs (if supported by the server)." diff --git a/administrator/language/en-GB/mod_menu.ini b/administrator/language/en-GB/mod_menu.ini index d7ebafdb67e82..1734cfa17c9db 100644 --- a/administrator/language/en-GB/mod_menu.ini +++ b/administrator/language/en-GB/mod_menu.ini @@ -109,6 +109,7 @@ MOD_MENU_MANAGE_LANGUAGES_CONTENT="Content Languages" MOD_MENU_MANAGE_LANGUAGES_OVERRIDES="Language Overrides" MOD_MENU_MANAGE_PLUGINS="Plugins" MOD_MENU_MANAGE_REDIRECTS="Redirects" +MOD_MENU_MANAGE_SCHEDULED_TASKS="Scheduled Tasks" MOD_MENU_MASS_MAIL_USERS="Mass Mail Users" MOD_MENU_MEDIA_MANAGER="Media" MOD_MENU_MENU_MANAGER="Manage" diff --git a/administrator/language/en-GB/plg_actionlog_joomla.ini b/administrator/language/en-GB/plg_actionlog_joomla.ini index 18a905d74f71e..5bfee992e3551 100644 --- a/administrator/language/en-GB/plg_actionlog_joomla.ini +++ b/administrator/language/en-GB/plg_actionlog_joomla.ini @@ -34,6 +34,7 @@ PLG_ACTIONLOG_JOOMLA_TYPE_PACKAGE="package" PLG_ACTIONLOG_JOOMLA_TYPE_PLUGIN="plugin" PLG_ACTIONLOG_JOOMLA_TYPE_STYLE="template style" PLG_ACTIONLOG_JOOMLA_TYPE_TAG="tag" +PLG_ACTIONLOG_JOOMLA_TYPE_TASK="Task" PLG_ACTIONLOG_JOOMLA_TYPE_TEMPLATE="template" PLG_ACTIONLOG_JOOMLA_TYPE_USER="user" PLG_ACTIONLOG_JOOMLA_TYPE_USER_GROUP="user group" @@ -45,8 +46,8 @@ PLG_ACTIONLOG_JOOMLA_USER_LOGEXPORT="User {username} PLG_ACTIONLOG_JOOMLA_USER_LOGGED_IN="User {username} logged in to {app}" PLG_ACTIONLOG_JOOMLA_USER_LOGGED_OUT="User {username} logged out from {app}" PLG_ACTIONLOG_JOOMLA_USER_LOGIN_FAILED="A failed attempt was made to login as {username} to {app}" -PLG_ACTIONLOG_JOOMLA_USER_REGISTRATION_ACTIVATE="User {username} activated the account" PLG_ACTIONLOG_JOOMLA_USER_REGISTERED="User {username} registered for an account" +PLG_ACTIONLOG_JOOMLA_USER_REGISTRATION_ACTIVATE="User {username} activated the account" PLG_ACTIONLOG_JOOMLA_USER_REMIND="User {username} requested a username reminder for their account" PLG_ACTIONLOG_JOOMLA_USER_RESET_COMPLETE="User {username} completed the password reset for their account" PLG_ACTIONLOG_JOOMLA_USER_RESET_REQUEST="User {username} requested a password reset for their account" diff --git a/administrator/language/en-GB/plg_system_schedulerunner.ini b/administrator/language/en-GB/plg_system_schedulerunner.ini new file mode 100644 index 0000000000000..4c638f443e558 --- /dev/null +++ b/administrator/language/en-GB/plg_system_schedulerunner.ini @@ -0,0 +1,6 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 +PLG_SYSTEM_SCHEDULERUNNER="System - Schedule Runner" +PLG_SYSTEM_SCHEDULERUNNER_XML_DESCRIPTION="This plugin is responsible for the lazy scheduling, webcron and click to run functionalities of com_scheduler. Besides that, this also implements form enhancers/manipulators for the com_scheduler component configuration." diff --git a/administrator/language/en-GB/plg_system_schedulerunner.sys.ini b/administrator/language/en-GB/plg_system_schedulerunner.sys.ini new file mode 100644 index 0000000000000..4c638f443e558 --- /dev/null +++ b/administrator/language/en-GB/plg_system_schedulerunner.sys.ini @@ -0,0 +1,6 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; License GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 +PLG_SYSTEM_SCHEDULERUNNER="System - Schedule Runner" +PLG_SYSTEM_SCHEDULERUNNER_XML_DESCRIPTION="This plugin is responsible for the lazy scheduling, webcron and click to run functionalities of com_scheduler. Besides that, this also implements form enhancers/manipulators for the com_scheduler component configuration." diff --git a/build/media_source/com_scheduler/css/admin-view-select-task.css b/build/media_source/com_scheduler/css/admin-view-select-task.css new file mode 100644 index 0000000000000..f92c756b956da --- /dev/null +++ b/build/media_source/com_scheduler/css/admin-view-select-task.css @@ -0,0 +1,58 @@ +.new-task { + display: flex; + overflow: hidden; + color: hsl(var(--hue), 30%, 40%); + background-color: hsl(var(--hue), 60%, 97%); + border: 1px solid hsl(var(--hue), 50%, 93%); + border-radius: .25rem; +} + +.new-task-title { + margin-bottom: .25rem; + font-size: 1rem; + font-weight: 700; +} + +.new-task-link { + display: flex; + align-items: flex-end; + justify-content: center; + width: 2.5rem; + font-size: 1.2rem; + background: hsl(var(--hue), 50%, 93%); +} + +.new-task-caption { + display: box; + margin: 0; + overflow: hidden; + font-size: .875rem; + box-orient: vertical; + -webkit-line-clamp: 3; +} + +.new-task-details { + flex: 1 0; + padding: 1rem; +} + +.new-task * { + transition: all .25s ease; +} + +.new-task:hover .new-task-link { + background: var(--template-bg-dark); +} + +.new-task-link span { + margin-bottom: 10px; + color: hsl(var(--hue), 30%, 40%); +} + +.new-task:hover .new-task-link span { + color: #fff; +} + +.new-tasks .card-columns { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) !important; +} diff --git a/build/media_source/com_scheduler/css/admin-view-task.css b/build/media_source/com_scheduler/css/admin-view-task.css new file mode 100644 index 0000000000000..fa9fea928e813 --- /dev/null +++ b/build/media_source/com_scheduler/css/admin-view-task.css @@ -0,0 +1,24 @@ +.match-custom .control-group .control-label { + width: auto; + padding: 0 0 0 0; +} + +.match-custom .control-group .controls { + /* flex: 1; */ + display: inline-block; + min-width: 7rem; +} + +.match-custom .control-group { + /* display: flex; */ + display: inline-block; + margin-right: 1.5rem; +} + +.match-custom label { + font-size: small; +} + +.match-custom select[multiple] { + height: 15rem; +} diff --git a/build/media_source/com_scheduler/joomla.asset.json b/build/media_source/com_scheduler/joomla.asset.json new file mode 100644 index 0000000000000..f09df3fa3e4d6 --- /dev/null +++ b/build/media_source/com_scheduler/joomla.asset.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json", + "name": "com_scheduler", + "version": "4.0.0", + "description": "Joomla CMS", + "license": "GNU General Public License version 2 or later; see LICENSE.txt", + "assets": [ + { + "name": "com_scheduler.test-task.es5", + "type": "script", + "uri": "com_scheduler/admin-view-run-test-task-es5.js", + "dependencies": [ + "core" + ], + "attributes": { + "nomodule": true, + "defer": true + } + }, + { + "name": "com_scheduler.test-task", + "type": "script", + "uri": "com_scheduler/admin-view-run-test-task.js", + "dependencies": [ + "com_scheduler.test-task.es5" + ], + "attributes": { + "type" : "module" + } + }, + { + "name": "com_scheduler.admin-view-select-task-search.es5", + "type": "script", + "uri": "com_scheduler/admin-view-select-task-search-es5.js", + "dependencies": [ + "core" + ], + "attributes": { + "nomodule": true, + "defer": true + } + }, + { + "name": "com_scheduler.admin-view-select-task-search", + "type": "script", + "uri": "com_scheduler/admin-view-select-task-search.js", + "dependencies": [ + "com_scheduler.admin-view-select-task-search.es5" + ], + "attributes": { + "type": "module" + } + }, + { + "name": "com_scheduler.scheduler-config.es5", + "type": "script", + "uri": "com_scheduler/scheduler-config-es5.js", + "dependencies": [ + "core" + ], + "attributes": { + "nomodule": true + } + }, + { + "name": "com_scheduler.scheduler-config", + "type": "script", + "uri": "com_scheduler/scheduler-config.js", + "dependencies": [ + "core", + "com_scheduler.scheduler-config.es5" + ], + "attributes": { + "type": "module" + } + }, + { + "name": "com_scheduler.admin-view-select-task-css", + "type": "style", + "uri": "com_scheduler/admin-view-select-task.css" + }, + { + "name": "com_scheduler.admin-view-task-css", + "type": "style", + "uri": "com_scheduler/admin-view-task.css" + } + ] +} diff --git a/build/media_source/com_scheduler/js/admin-view-run-test-task.es6.js b/build/media_source/com_scheduler/js/admin-view-run-test-task.es6.js new file mode 100644 index 0000000000000..49b1b2af420fb --- /dev/null +++ b/build/media_source/com_scheduler/js/admin-view-run-test-task.es6.js @@ -0,0 +1,92 @@ +/** + * @copyright (C) 2021 Open Source Matters, Inc. + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +/** + * Provides the manual-run functionality for tasks over the com_scheduler administrator backend. + * + * @package Joomla.Components + * @subpackage Scheduler.Tasks + * + * @since __DEPLOY_VERSION__ + */ +if (!window.Joomla) { + throw new Error('Joomla API was not properly initialised'); +} + +const initRunner = () => { + const paths = Joomla.getOptions('system.paths'); + const uri = `${paths ? `${paths.base}/index.php` : window.location.pathname}?option=com_ajax&format=json&plugin=RunSchedulerTest&group=system&id=%d`; + const modal = document.getElementById('scheduler-test-modal'); + + // Task output template + const template = ` +

${Joomla.Text._('COM_SCHEDULER_TEST_RUN_TASK')}

+
${Joomla.Text._('COM_SCHEDULER_TEST_RUN_STATUS_STARTED')}
+
+ `; + + const sanitiseTaskOutput = (text) => text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1
$2'); + + // Trigger the task through a GET request, populate the modal with output on completion. + const triggerTaskAndShowOutput = (e) => { + const button = e.relatedTarget; + const id = parseInt(button.dataset.id, 10); + const { title } = button.dataset; + + modal.querySelector('.modal-title').innerHTML = Joomla.Text._('COM_SCHEDULER_TEST_RUN_TITLE').replace('%d', id); + modal.querySelector('.modal-body > div').innerHTML = template.replace('%s', title); + + Joomla.request({ + url: uri.replace('%d', id.toString()), + onSuccess: (data, xhr) => { + [].slice.call(modal.querySelectorAll('.modal-body > div > div')).forEach((el) => { + el.parentNode.removeChild(el); + }); + + const output = JSON.parse(data); + + if (output && output.success && output.data) { + modal.querySelector('.modal-body > div').innerHTML += `
${Joomla.Text._('COM_SCHEDULER_TEST_RUN_STATUS_COMPLETED')}
`; + + if (output.data.duration > 0) { + modal.querySelector('.modal-body > div').innerHTML += `
${Joomla.Text._('COM_SCHEDULER_TEST_RUN_DURATION').replace('%s', output.data.duration.toFixed(2))}
`; + } + + if (output.data.output) { + const result = Joomla.sanitizeHtml((output.data.output), null, sanitiseTaskOutput); + + // Can use an indication for non-0 exit codes + modal.querySelector('.modal-body > div').innerHTML += `
${Joomla.Text._('COM_SCHEDULER_TEST_RUN_OUTPUT').replace('%s', result)}
`; + } + } else { + modal.querySelector('.modal-body > div').innerHTML += `
${Joomla.Text._('COM_SCHEDULER_TEST_RUN_STATUS_TERMINATED')}
`; + modal.querySelector('.modal-body > div').innerHTML += `
${Joomla.Text._('COM_SCHEDULER_TEST_RUN_OUTPUT').replace('%s', Joomla.Text._('JLIB_JS_AJAX_ERROR_OTHER').replace('%s', xhr.status))}
`; + } + }, + onError: (xhr) => { + modal.querySelector('.modal-body > div').innerHTML += `
${Joomla.Text._('COM_SCHEDULER_TEST_RUN_STATUS_TERMINATED')}
`; + + const msg = Joomla.ajaxErrorsMessages(xhr); + modal.querySelector('.modal-body > div').innerHTML += `
${Joomla.Text._('COM_SCHEDULER_TEST_RUN_OUTPUT').replace('%s', msg.error)}
`; + }, + }); + }; + + const reloadOnClose = () => { + window.location.href = `${paths ? `${paths.base}/index.php` : window.location.pathname}?option=com_scheduler&view=tasks`; + }; + + modal.addEventListener('show.bs.modal', triggerTaskAndShowOutput); + modal.addEventListener('hidden.bs.modal', reloadOnClose); + document.removeEventListener('DOMContentLoaded', initRunner); +}; + +document.addEventListener('DOMContentLoaded', initRunner); diff --git a/build/media_source/com_scheduler/js/admin-view-select-task-search.es6.js b/build/media_source/com_scheduler/js/admin-view-select-task-search.es6.js new file mode 100644 index 0000000000000..78215fb8b7857 --- /dev/null +++ b/build/media_source/com_scheduler/js/admin-view-select-task-search.es6.js @@ -0,0 +1,107 @@ +/** + * @copyright (C) 2021 Open Source Matters, Inc. + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +/** + * Add a keyboard event listener to the Select a Task Type search element. + * + * IMPORTANT! This script is meant to be loaded deferred. This means that a. it's non-blocking + * (the browser can load it whenever) and b. it doesn't need an on DOMContentLoaded event handler + * because the browser is guaranteed to execute it only after the DOM content has loaded, the + * whole point of it being deferred. + * + * The search box has a keyboard handler that fires every time you press a keyboard button or send + * a keypress with a touch / virtual keyboard. We then iterate all task type cards and check if + * the plain text (HTML stripped out) representation of the task title or description partially + * matches the text you entered in the search box. If it doesn't we add a Bootstrap class to hide + * the task. + * + * This way we limit the displayed tasks only to those searched. + * + * This feature follows progressive enhancement. The search box is hidden by default and only + * displayed when this JavaScript here executes. Furthermore, session storage is only used if it + * is available in the browser. That's a bit of a pain but makes sure things won't break in older + * browsers. + * + * Furthermore and to facilitate the user experience we auto-focus the search element which has a + * suitable title so that non-sighted users are not startled. This way we address both UX concerns + * and accessibility. + * + * Finally, the search string is saved into session storage on the assumption that the user is + * probably going to be creating multiple instances of the same task, one after another, as is + * typical when building a new Joomla! site. + * phpcs:ignoreFile + */ +// Make sure the element exists i.e. a template override has not removed it. +const elSearch = document.getElementById('comSchedulerSelectSearch'); +const elSearchContainer = document.getElementById('comSchedulerSelectSearchContainer'); +const elSearchHeader = document.getElementById('comSchedulerSelectTypeHeader'); +const elSearchResults = document.getElementById('comSchedulerSelectResultsContainer'); +const alertElement = document.querySelector('.tasks-alert'); +const elCards = [].slice.call(document.querySelectorAll('.comSchedulerSelectCard')); + +if (elSearch && elSearchContainer) { + // Add the keyboard event listener which performs the live search in the cards + elSearch.addEventListener('keyup', ({ target }) => { + /** @type {KeyboardEvent} event */ + const partialSearch = target.value; + let hasSearchResults = false; // Save the search string into session storage + + if (typeof sessionStorage !== 'undefined') { + sessionStorage.setItem('Joomla.com_scheduler.new.search', partialSearch); + } + + // Iterate over all the task cards + elCards.forEach((card) => { + // First remove the class which hide the task cards + card.classList.remove('d-none'); + + // An empty search string means that we should show everything + if (!partialSearch) { + return; + } + + const cardHeader = card.querySelector('.new-task-title'); + const cardBody = card.querySelector('.card-body'); + const title = cardHeader ? cardHeader.textContent : ''; + const description = cardBody ? cardBody.textContent : ''; + + // If the task title and description don’t match add a class to hide it. + if (title && !title.toLowerCase().includes(partialSearch.toLowerCase()) + && description && !description.toLowerCase().includes(partialSearch.toLowerCase())) { + card.classList.add('d-none'); + } else { + hasSearchResults = true; + } + }); + + if (hasSearchResults || !partialSearch) { + alertElement.classList.add('d-none'); + elSearchHeader.classList.remove('d-none'); + elSearchResults.classList.remove('d-none'); + } else { + alertElement.classList.remove('d-none'); + elSearchHeader.classList.add('d-none'); + elSearchResults.classList.add('d-none'); + } + }); + + // For reasons of progressive enhancement the search box is hidden by default. + elSearchContainer.classList.remove('d-none'); + + // Focus the just show element + elSearch.focus(); + + try { + if (typeof sessionStorage !== 'undefined') { + // Load the search string from session storage + elSearch.value = sessionStorage.getItem('Joomla.com_scheduler.new.search') || ''; + + // Trigger the keyboard handler event manually to initiate the search + elSearch.dispatchEvent(new KeyboardEvent('keyup')); + } + } catch (e) { + // This is probably Internet Explorer which doesn't support the KeyboardEvent constructor :( + } +} diff --git a/build/media_source/com_scheduler/js/scheduler-config.es6.js b/build/media_source/com_scheduler/js/scheduler-config.es6.js new file mode 100644 index 0000000000000..b980848ad2ce8 --- /dev/null +++ b/build/media_source/com_scheduler/js/scheduler-config.es6.js @@ -0,0 +1,51 @@ +/** + * @copyright (C) 2021 Open Source Matters, Inc. + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +if (!window.Joomla) { + throw new Error('Joomla API was not properly initialised!'); +} + +const copyToClipboardFallback = (input) => { + input.focus(); + input.select(); + + try { + const copy = document.execCommand('copy'); + if (copy) { + Joomla.renderMessages({ message: [Joomla.Text._('COM_SCHEDULER_CONFIG_WEBCRON_LINK_COPY_SUCCESS')] }); + } else { + Joomla.renderMessages({ error: [Joomla.Text._('COM_SCHEDULER_CONFIG_WEBCRON_LINK_COPY_FAIL')] }); + } + } catch (err) { + Joomla.renderMessages({ error: [err] }); + } +}; + +const copyToClipboard = () => { + const button = document.getElementById('link-copy'); + + button.addEventListener('click', ({ currentTarget }) => { + const input = currentTarget.previousElementSibling; + + if (!navigator.clipboard) { + copyToClipboardFallback(input); + return; + } + + navigator.clipboard.writeText(input.value).then(() => { + Joomla.renderMessages({ message: [Joomla.Text._('COM_SCHEDULER_CONFIG_WEBCRON_LINK_COPY_SUCCESS')] }); + }, () => { + Joomla.renderMessages({ error: [Joomla.Text._('COM_SCHEDULER_CONFIG_WEBCRON_LINK_COPY_FAIL')] }); + }); + }); +}; + +const onBoot = () => { + copyToClipboard(); + + document.removeEventListener('DOMContentLoaded', onBoot); +}; + +document.addEventListener('DOMContentLoaded', onBoot); diff --git a/build/media_source/plg_system_schedulerunner/joomla.asset.json b/build/media_source/plg_system_schedulerunner/joomla.asset.json new file mode 100644 index 0000000000000..0feb3c9850ef5 --- /dev/null +++ b/build/media_source/plg_system_schedulerunner/joomla.asset.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json", + "name": "plg_system_schedulerunner", + "version": "4.0.0", + "description": "Joomla CMS", + "license": "GPL-2.0-or-later", + "assets": [ + { + "name": "plg_system_schedulerunner.run-schedule.es5", + "type": "script", + "uri": "plg_system_schedulerunner/run-schedule-es5.min.js", + "dependencies": [ + "core" + ], + "attributes": { + "nomodule": true, + "defer": true + } + }, + { + "name": "plg_system_schedulerunner.run-schedule", + "type": "script", + "uri": "plg_system_schedulerunner/run-schedule.min.js", + "dependencies": [ + "plg_system_schedulerunner.run-schedule.es5", + "core" + ], + "atrributes": { + "nomodule": true, + "defer": true + } + } + ] +} diff --git a/build/media_source/plg_system_schedulerunner/js/run-schedule.es6.js b/build/media_source/plg_system_schedulerunner/js/run-schedule.es6.js new file mode 100644 index 0000000000000..c2d1e92418a82 --- /dev/null +++ b/build/media_source/plg_system_schedulerunner/js/run-schedule.es6.js @@ -0,0 +1,36 @@ +/** + * @copyright (C) 2021 Open Source Matters, Inc. + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +/** + * Makes calls to com_ajax to trigger the Scheduler. + * + * Used for lazy scheduling of tasks. + * + * @package Joomla.Plugins + * @subpackage System.ScheduleRunner + * + * @since __DEPLOY_VERSION__ + */ +if (!window.Joomla) { + throw new Error('Joomla API was not properly initialised'); +} + +const initScheduler = () => { + const options = Joomla.getOptions('plg_system_schedulerunner'); + const paths = Joomla.getOptions('system.paths'); + const interval = (options && options.inverval ? parseInt(options.interval, 10) : 300) * 1000; + const uri = `${paths ? `${paths.root}/index.php` : window.location.pathname}?option=com_ajax&format=raw&plugin=RunSchedulerLazy&group=system`; + + setInterval(() => navigator.sendBeacon(uri), interval); + + // Run it at the beginning at least once + navigator.sendBeacon(uri); +}; + +((document) => { + document.addEventListener('DOMContentLoaded', () => { + initScheduler(); + }); +})(document); diff --git a/composer.json b/composer.json index c760f66ef3f7c..064ed320b4c03 100644 --- a/composer.json +++ b/composer.json @@ -82,7 +82,8 @@ "psr/log": "~1.0", "ext-gd": "*", "web-auth/webauthn-lib": "2.1.*", - "composer/ca-bundle": "^1.2" + "composer/ca-bundle": "^1.2", + "dragonmantank/cron-expression": "^3.1" }, "require-dev": { "phpunit/phpunit": "^8.5", diff --git a/composer.lock b/composer.lock index df658ab10845b..92776fb40dbcc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7a38a492e1140d3acdd45a4fb7f42486", + "content-hash": "f35173335d5258a86474fcd8e70fdd58", "packages": [ { "name": "algo26-matthias/idna-convert", @@ -423,6 +423,67 @@ ], "time": "2021-04-16T17:34:40+00:00" }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.1.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c", + "reference": "7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0", + "webmozart/assert": "^1.7.0" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-webmozart-assert": "^0.12.7", + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.1.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2020-11-24T19:55:57+00:00" + }, { "name": "fgrosse/phpasn1", "version": "v2.3.0", @@ -5185,6 +5246,64 @@ }, "time": "2019-09-09T12:04:09+00:00" }, + { + "name": "webmozart/assert", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/6964c76c7804814a842473e0c8fd15bab0f18e25", + "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.10.0" + }, + "time": "2021-03-09T10:59:23+00:00" + }, { "name": "willdurand/negotiation", "version": "3.0.0", @@ -10378,64 +10497,6 @@ } ], "time": "2021-07-28T10:34:58+00:00" - }, - { - "name": "webmozart/assert", - "version": "1.10.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/6964c76c7804814a842473e0c8fd15bab0f18e25", - "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.10.0" - }, - "time": "2021-03-09T10:59:23+00:00" } ], "aliases": [], diff --git a/installation/sql/mysql/base.sql b/installation/sql/mysql/base.sql index 82fd5d03509e1..1ae991be460bf 100644 --- a/installation/sql/mysql/base.sql +++ b/installation/sql/mysql/base.sql @@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS `#__assets` ( -- INSERT INTO `#__assets` (`id`, `parent_id`, `lft`, `rgt`, `level`, `name`, `title`, `rules`) VALUES -(1, 0, 0, 161, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'), +(1, 0, 0, 163, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'), (2, 1, 1, 2, 1, 'com_admin', 'com_admin', '{}'), (3, 1, 3, 6, 1, 'com_banners', 'com_banners', '{"core.admin":{"7":1},"core.manage":{"6":1}}'), (4, 1, 7, 8, 1, 'com_cache', 'com_cache', '{"core.admin":{"7":1},"core.manage":{"7":1}}'), @@ -105,7 +105,8 @@ INSERT INTO `#__assets` (`id`, `parent_id`, `lft`, `rgt`, `level`, `name`, `titl (85, 18, 120, 121, 2, 'com_modules.module.108', 'Privacy Status', '{}'), (86, 18, 122, 123, 2, 'com_modules.module.96', 'Popular Articles', '{}'), (87, 18, 124, 125, 2, 'com_modules.module.97', 'Recently Added Articles', '{}'), -(88, 18, 126, 127, 2, 'com_modules.module.98', 'Logged-in Users', '{}'); +(88, 18, 126, 127, 2, 'com_modules.module.98', 'Logged-in Users', '{}'), +(89, 1, 161, 162, 1, 'com_scheduler', 'com_scheduler', '{}'); -- -------------------------------------------------------- @@ -179,7 +180,8 @@ INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, (0, 'com_privacy', 'component', 'com_privacy', '', 1, 1, 1, 0, 1, '', '', ''), (0, 'com_actionlogs', 'component', 'com_actionlogs', '', 1, 1, 1, 0, 1, '', '{"ip_logging":0,"csv_delimiter":",","loggable_extensions":["com_banners","com_cache","com_categories","com_checkin","com_config","com_contact","com_content","com_installer","com_media","com_menus","com_messages","com_modules","com_newsfeeds","com_plugins","com_redirect","com_tags","com_templates","com_users"]}', ''), (0, 'com_workflow', 'component', 'com_workflow', '', 1, 1, 0, 1, 1, '', '{}', ''), -(0, 'com_mails', 'component', 'com_mails', '', 1, 1, 1, 1, 1, '', '', ''); +(0, 'com_mails', 'component', 'com_mails', '', 1, 1, 1, 1, 1, '', '', ''), +(0, 'com_scheduler', 'component', 'com_scheduler', '', 1, 1, 1, 0, 1, '', '{}', ''); -- Libraries INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, `client_id`, `enabled`, `access`, `protected`, `locked`, `manifest_cache`, `params`, `custom_data`) VALUES @@ -334,12 +336,18 @@ INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`, (0, 'plg_system_privacyconsent', 'plugin', 'privacyconsent', 'system', 0, 0, 1, 0, 1, '', '{}', '', 13, 0), (0, 'plg_system_redirect', 'plugin', 'redirect', 'system', 0, 0, 1, 0, 1, '', '', '', 14, 0), (0, 'plg_system_remember', 'plugin', 'remember', 'system', 0, 1, 1, 0, 1, '', '', '', 15, 0), +(0, 'plg_system_schedulerunner', 'plugin', 'schedulerunner', 'system', 0, 1, 1, 0, 0, '', '{}', '', 15, 0), (0, 'plg_system_sef', 'plugin', 'sef', 'system', 0, 1, 1, 0, 1, '', '', '', 16, 0), (0, 'plg_system_sessiongc', 'plugin', 'sessiongc', 'system', 0, 1, 1, 0, 1, '', '', '', 17, 0), (0, 'plg_system_skipto', 'plugin', 'skipto', 'system', 0, 1, 1, 0, 1, '', '{}', '', 18, 0), (0, 'plg_system_stats', 'plugin', 'stats', 'system', 0, 1, 1, 0, 1, '', '', '', 19, 0), +(0, 'plg_system_tasknotification', 'plugin', 'tasknotification', 'system', 0, 1, 1, 0, 1, '', '', '', 22, 0), (0, 'plg_system_updatenotification', 'plugin', 'updatenotification', 'system', 0, 1, 1, 0, 1, '', '', '', 20, 0), (0, 'plg_system_webauthn', 'plugin', 'webauthn', 'system', 0, 1, 1, 0, 1, '', '{}', '', 21, 0), +(0, 'plg_task_checkfiles', 'plugin', 'checkfiles', 'task', 0, 1, 1, 0, 0, '', '{}', '', 15, 0), +(0, 'plg_task_demotasks', 'plugin', 'demotasks', 'task', 0, 1, 1, 0, 0, '', '{}', '', 15, 0), +(0, 'plg_task_requests', 'plugin', 'requests', 'task', 0, 1, 1, 0, 0, '', '{}', '', 15, 0), +(0, 'plg_task_sitestatus', 'plugin', 'sitestatus', 'task', 0, 1, 1, 0, 0, '', '{}', '', 15, 0), (0, 'plg_twofactorauth_totp', 'plugin', 'totp', 'twofactorauth', 0, 0, 1, 0, 1, '', '', '', 1, 0), (0, 'plg_twofactorauth_yubikey', 'plugin', 'yubikey', 'twofactorauth', 0, 0, 1, 0, 1, '', '', '', 2, 0), (0, 'plg_user_contactcreator', 'plugin', 'contactcreator', 'user', 0, 0, 1, 0, 1, '', '{"autowebpage":"","category":"4","autopublish":"0"}', '', 1, 0), diff --git a/installation/sql/mysql/extensions.sql b/installation/sql/mysql/extensions.sql index 52a6c316af3e5..f37b17ece25e7 100644 --- a/installation/sql/mysql/extensions.sql +++ b/installation/sql/mysql/extensions.sql @@ -829,7 +829,8 @@ INSERT INTO `#__action_logs_extensions` (`id`, `extension`) VALUES (15, 'com_tags'), (16, 'com_templates'), (17, 'com_users'), -(18, 'com_checkin'); +(18, 'com_checkin'), +(19, 'com_scheduler'); -- -------------------------------------------------------- @@ -867,7 +868,8 @@ INSERT INTO `#__action_log_config` (`id`, `type_title`, `type_alias`, `id_holder (16, 'module', 'com_modules.module', 'id' ,'title', '#__modules', 'PLG_ACTIONLOG_JOOMLA'), (17, 'access_level', 'com_users.level', 'id' , 'title', '#__viewlevels', 'PLG_ACTIONLOG_JOOMLA'), (18, 'banner_client', 'com_banners.client', 'id', 'name', '#__banner_clients', 'PLG_ACTIONLOG_JOOMLA'), -(19, 'application_config', 'com_config.application', '', 'name', '', 'PLG_ACTIONLOG_JOOMLA'); +(19, 'application_config', 'com_config.application', '', 'name', '', 'PLG_ACTIONLOG_JOOMLA'), +(20, 'task', 'com_scheduler.task', 'id', 'title', '#__scheduler_tasks', 'PLG_ACTIONLOG_JOOMLA'); -- -------------------------------------------------------- @@ -882,3 +884,45 @@ CREATE TABLE IF NOT EXISTS `#__action_logs_users` ( PRIMARY KEY (`user_id`), KEY `idx_notify` (`notify`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Table structure for table `#__scheduler_tasks` +-- + +CREATE TABLE IF NOT EXISTS `#__scheduler_tasks` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `asset_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'FK to the #__assets table.', + `title` varchar(255) NOT NULL DEFAULT '', + `type` varchar(128) NOT NULL COMMENT 'unique identifier for job defined by plugin', + `execution_rules` text COMMENT 'Execution Rules, Unprocessed', + `cron_rules` text COMMENT 'Processed execution rules, crontab-like JSON form', + `state` tinyint NOT NULL DEFAULT FALSE, + `last_exit_code` int NOT NULL DEFAULT 0 COMMENT 'Exit code when job was last run', + `last_execution` datetime COMMENT 'Timestamp of last run', + `next_execution` datetime COMMENT 'Timestamp of next (planned) run, referred for execution on trigger', + `times_executed` int DEFAULT 0 COMMENT 'Count of successful triggers', + `times_failed` int DEFAULT 0 COMMENT 'Count of failures', + `locked` datetime, + `priority` smallint NOT NULL DEFAULT 0, + `ordering` int NOT NULL DEFAULT 0 COMMENT 'Configurable list ordering', + `cli_exclusive` smallint NOT NULL DEFAULT 0 COMMENT 'If 1, the task is only accessible via CLI', + `params` text NOT NULL, + `note` text, + `created` datetime NOT NULL, + `created_by` int UNSIGNED NOT NULL DEFAULT 0, + `checked_out` int unsigned, + `checked_out_time` datetime, + PRIMARY KEY (id), + KEY `idx_type` (`type`), + KEY `idx_state` (`state`), + KEY `idx_last_exit` (`last_exit_code`), + KEY `idx_next_exec` (`next_execution`), + KEY `idx_locked` (`locked`), + KEY `idx_priority` (`priority`), + KEY `idx_cli_exclusive` (`cli_exclusive`), + KEY `idx_checked_out` (`checked_out`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; + +-- -------------------------------------------------------- diff --git a/installation/sql/mysql/supports.sql b/installation/sql/mysql/supports.sql index 80304d524482f..1b60a4c48935c 100644 --- a/installation/sql/mysql/supports.sql +++ b/installation/sql/mysql/supports.sql @@ -436,4 +436,8 @@ INSERT INTO `#__mail_templates` (`template_id`, `extension`, `language`, `subjec ('com_users.registration.user.admin_activated', 'com_users', '', 'COM_USERS_EMAIL_ACTIVATED_BY_ADMIN_ACTIVATION_SUBJECT', 'COM_USERS_EMAIL_ACTIVATED_BY_ADMIN_ACTIVATION_BODY', '', '', '{"tags":["name","sitename","siteurl","username"]}'), ('com_users.registration.admin.verification_request', 'com_users', '', 'COM_USERS_EMAIL_ACTIVATE_WITH_ADMIN_ACTIVATION_SUBJECT', 'COM_USERS_EMAIL_ACTIVATE_WITH_ADMIN_ACTIVATION_BODY', '', '', '{"tags":["name","sitename","email","username","activate"]}'), ('plg_system_privacyconsent.request.reminder', 'plg_system_privacyconsent', '', 'PLG_SYSTEM_PRIVACYCONSENT_EMAIL_REMIND_SUBJECT', 'PLG_SYSTEM_PRIVACYCONSENT_EMAIL_REMIND_BODY', '', '', '{"tags":["sitename","url","tokenurl","formurl","token"]}'), -('com_messages.new_message', 'com_messages', '', 'COM_MESSAGES_NEW_MESSAGE', 'COM_MESSAGES_NEW_MESSAGE_BODY', '', '', '{"tags":["subject","message","fromname","sitename","siteurl","fromemail","toname","toemail"]}'); +('com_messages.new_message', 'com_messages', '', 'COM_MESSAGES_NEW_MESSAGE', 'COM_MESSAGES_NEW_MESSAGE_BODY', '', '', '{"tags":["subject","message","fromname","sitename","siteurl","fromemail","toname","toemail"]}'), +('plg_system_tasknotification.failure_mail', 'plg_system_tasknotification', '', 'PLG_SYSTEM_TASK_NOTIFICATION_FAILURE_MAIL_SUBJECT', 'PLG_SYSTEM_TASK_NOTIFICATION_FAILURE_MAIL_BODY', '', '', '{"tags": ["task_id", "task_title", "exit_code", "exec_data_time", "task_output"]}'), +('plg_system_tasknotification.fatal_recovery_mail', 'plg_system_tasknotification', '', 'PLG_SYSTEM_TASK_NOTIFICATION_FATAL_MAIL_SUBJECT', 'PLG_SYSTEM_TASK_NOTIFICATION_FATAL_MAIL_BODY', '', '', '{"tags": ["task_id", "task_title"]}'), +('plg_system_tasknotification.orphan_mail', 'plg_system_tasknotification', '', 'PLG_SYSTEM_TASK_NOTIFICATION_ORPHAN_MAIL_SUBJECT', 'PLG_SYSTEM_TASK_NOTIFICATION_ORPHAN_MAIL_BODY', '', '', '{"tags": ["task_id", "task_title", ""]}'), +('plg_system_tasknotification.success_mail', 'plg_system_tasknotification', '', 'PLG_SYSTEM_TASK_NOTIFICATION_SUCCESS_MAIL_SUBJECT', 'PLG_SYSTEM_TASK_NOTIFICATION_SUCCESS_MAIL_BODY', '', '', '{"tags":["task_id", "task_title", "exec_data_time", "task_output"]}'); diff --git a/installation/sql/postgresql/base.sql b/installation/sql/postgresql/base.sql index 8786f98bd20e3..e44188862d656 100644 --- a/installation/sql/postgresql/base.sql +++ b/installation/sql/postgresql/base.sql @@ -31,7 +31,7 @@ COMMENT ON COLUMN "#__assets"."rules" IS 'JSON encoded access control.'; -- INSERT INTO "#__assets" ("id", "parent_id", "lft", "rgt", "level", "name", "title", "rules") VALUES -(1, 0, 0, 161, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'), +(1, 0, 0, 163, 0, 'root.1', 'Root Asset', '{"core.login.site":{"6":1,"2":1},"core.login.admin":{"6":1},"core.login.api":{"8":1},"core.login.offline":{"6":1},"core.admin":{"8":1},"core.manage":{"7":1},"core.create":{"6":1,"3":1},"core.delete":{"6":1},"core.edit":{"6":1,"4":1},"core.edit.state":{"6":1,"5":1},"core.edit.own":{"6":1,"3":1}}'), (2, 1, 1, 2, 1, 'com_admin', 'com_admin', '{}'), (3, 1, 3, 6, 1, 'com_banners', 'com_banners', '{"core.admin":{"7":1},"core.manage":{"6":1}}'), (4, 1, 7, 8, 1, 'com_cache', 'com_cache', '{"core.admin":{"7":1},"core.manage":{"7":1}}'), @@ -111,7 +111,8 @@ INSERT INTO "#__assets" ("id", "parent_id", "lft", "rgt", "level", "name", "titl (85, 18, 120, 121, 2, 'com_modules.module.108', 'Privacy Status', '{}'), (86, 18, 122, 123, 2, 'com_modules.module.96', 'Popular Articles', '{}'), (87, 18, 124, 125, 2, 'com_modules.module.97', 'Recently Added Articles', '{}'), -(88, 18, 126, 127, 2, 'com_modules.module.98', 'Logged-in Users', '{}'); +(88, 18, 126, 127, 2, 'com_modules.module.98', 'Logged-in Users', '{}'), +(89, 1, 161, 162, 1, 'com_scheduler', 'com_scheduler', '{}'); SELECT setval('#__assets_id_seq', 89, false); @@ -185,7 +186,8 @@ INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", (0, 'com_privacy', 'component', 'com_privacy', '', 1, 1, 1, 0, 1, '', '', '', 0, 0), (0, 'com_actionlogs', 'component', 'com_actionlogs', '', 1, 1, 1, 0, 1, '', '{"ip_logging":0,"csv_delimiter":",","loggable_extensions":["com_banners","com_cache","com_categories","com_checkin","com_config","com_contact","com_content","com_installer","com_media","com_menus","com_messages","com_modules","com_newsfeeds","com_plugins","com_redirect","com_tags","com_templates","com_users"]}', '', 0, 0), (0, 'com_workflow', 'component', 'com_workflow', '', 1, 1, 0, 1, 1, '', '{}', '', 0, 0), -(0, 'com_mails', 'component', 'com_mails', '', 1, 1, 1, 1, 1, '', '', '', 0, 0); +(0, 'com_mails', 'component', 'com_mails', '', 1, 1, 1, 1, 1, '', '', '', 0, 0), +(0, 'com_scheduler', 'component', 'com_scheduler', '', 1, 1, 1, 0, 1, '', '{}', '', 0, 0); -- Libraries INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", "client_id", "enabled", "access", "protected", "locked", "manifest_cache", "params", "custom_data", "ordering", "state") VALUES @@ -340,12 +342,18 @@ INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder", (0, 'plg_system_privacyconsent', 'plugin', 'privacyconsent', 'system', 0, 0, 1, 0, 1, '', '{}', '', 13, 0), (0, 'plg_system_redirect', 'plugin', 'redirect', 'system', 0, 0, 1, 0, 1, '', '', '', 14, 0), (0, 'plg_system_remember', 'plugin', 'remember', 'system', 0, 1, 1, 0, 1, '', '', '', 15, 0), +(0, 'plg_system_schedulerunner', 'plugin', 'schedulerunner', 'system', 0, 1, 1, 0, 0, '', '{}', '', 15, 0), (0, 'plg_system_sef', 'plugin', 'sef', 'system', 0, 1, 1, 0, 1, '', '', '', 16, 0), (0, 'plg_system_sessiongc', 'plugin', 'sessiongc', 'system', 0, 1, 1, 0, 1, '', '', '', 17, 0), (0, 'plg_system_skipto', 'plugin', 'skipto', 'system', 0, 1, 1, 0, 1, '', '{}', '', 18, 0), (0, 'plg_system_stats', 'plugin', 'stats', 'system', 0, 1, 1, 0, 1, '', '', '', 19, 0), +(0, 'plg_system_tasknotification', 'plugin', 'tasknotification', 'system', 0, 1, 1, 0, 1, '', '', '', 22, 0), (0, 'plg_system_updatenotification', 'plugin', 'updatenotification', 'system', 0, 1, 1, 0, 1, '', '', '', 20, 0), (0, 'plg_system_webauthn', 'plugin', 'webauthn', 'system', 0, 1, 1, 0, 1, '', '{}', '', 21, 0), +(0, 'plg_task_checkfiles', 'plugin', 'checkfiles', 'task', 0, 1, 1, 0, 0, '', '{}', '', 15, 0), +(0, 'plg_task_demotasks', 'plugin', 'demotasks', 'task', 0, 1, 1, 0, 0, '', '{}', '', 15, 0), +(0, 'plg_task_requests', 'plugin', 'requests', 'task', 0, 1, 1, 0, 0, '', '{}', '', 15, 0), +(0, 'plg_task_sitestatus', 'plugin', 'sitestatus', 'task', 0, 1, 1, 0, 0, '', '{}', '', 15, 0), (0, 'plg_twofactorauth_totp', 'plugin', 'totp', 'twofactorauth', 0, 0, 1, 0, 1, '', '', '', 1, 0), (0, 'plg_twofactorauth_yubikey', 'plugin', 'yubikey', 'twofactorauth', 0, 0, 1, 0, 1, '', '', '', 2, 0), (0, 'plg_user_contactcreator', 'plugin', 'contactcreator', 'user', 0, 0, 1, 0, 1, '', '{"autowebpage":"","category":"4","autopublish":"0"}', '', 1, 0), diff --git a/installation/sql/postgresql/extensions.sql b/installation/sql/postgresql/extensions.sql index 628c12098f02f..699f6454c3cd5 100644 --- a/installation/sql/postgresql/extensions.sql +++ b/installation/sql/postgresql/extensions.sql @@ -787,7 +787,8 @@ INSERT INTO "#__action_logs_extensions" ("id", "extension") VALUES (15, 'com_tags'), (16, 'com_templates'), (17, 'com_users'), -(18, 'com_checkin'); +(18, 'com_checkin'), +(19, 'com_scheduler'); SELECT setval('#__action_logs_extensions_id_seq', 19, false); -- -------------------------------------------------------- @@ -828,7 +829,8 @@ INSERT INTO "#__action_log_config" ("id", "type_title", "type_alias", "id_holder (16, 'module', 'com_modules.module', 'id' ,'title', '#__modules', 'PLG_ACTIONLOG_JOOMLA'), (17, 'access_level', 'com_users.level', 'id' , 'title', '#__viewlevels', 'PLG_ACTIONLOG_JOOMLA'), (18, 'banner_client', 'com_banners.client', 'id', 'name', '#__banner_clients', 'PLG_ACTIONLOG_JOOMLA'), -(19, 'application_config', 'com_config.application', '', 'name', '', 'PLG_ACTIONLOG_JOOMLA'); +(19, 'application_config', 'com_config.application', '', 'name', '', 'PLG_ACTIONLOG_JOOMLA'), +(20, 'task', 'com_scheduler.task', 'id', 'title', '#__scheduler_tasks', 'PLG_ACTIONLOG_JOOMLA'); SELECT setval('#__action_log_config_id_seq', 20, false); @@ -846,6 +848,50 @@ CREATE TABLE "#__action_logs_users" ( CREATE INDEX "#__action_logs_users_idx_notify" ON "#__action_logs_users" ("notify"); +-- -------------------------------------------------------- + +-- +-- Table structure for table "#__scheduler_tasks" +-- + +CREATE TABLE IF NOT EXISTS "#__scheduler_tasks" +( + "id" serial NOT NULL, + "asset_id" bigint DEFAULT 0 NOT NULL, + "title" varchar(255) NOT NULL, + "type" varchar(128) NOT NULL, + "execution_rules" text, + "cron_rules" text, + "state" smallint DEFAULT 0 NOT NULL, + "last_exit_code" integer DEFAULT 0 NOT NULL, + "last_execution" timestamp without time zone, + "next_execution" timestamp without time zone, + "times_executed" integer DEFAULT 0 NOT NULL, + "times_failed" integer DEFAULT 0, + "locked" timestamp without time zone, + "priority" smallint DEFAULT 0 NOT NULL, + "ordering" bigint DEFAULT 0 NOT NULL, + "cli_exclusive" smallint DEFAULT 0 NOT NULL, + "params" text NOT NULL, + "note" text, + "created" timestamp without time zone NOT NULL, + "created_by" bigint DEFAULT 0 NOT NULL, + "checked_out" integer, + "checked_out_time" timestamp without time zone, + PRIMARY KEY ("id") +); + +CREATE INDEX "#__scheduler_tasks_idx_type" ON "#__scheduler_tasks" ("type"); +CREATE INDEX "#__scheduler_tasks_idx_state" ON "#__scheduler_tasks" ("state"); +CREATE INDEX "#__scheduler_tasks_idx_last_exit" ON "#__scheduler_tasks" ("last_exit_code"); +CREATE INDEX "#__scheduler_tasks_idx_next_exec" ON "#__scheduler_tasks" ("next_execution"); +CREATE INDEX "#__scheduler_tasks_idx_locked" ON "#__scheduler_tasks" ("locked"); +CREATE INDEX "#__scheduler_tasks_idx_priority" ON "#__scheduler_tasks" ("priority"); +CREATE INDEX "#__scheduler_tasks_idx_cli_exclusive" ON "#__scheduler_tasks" ("cli_exclusive"); +CREATE INDEX "#__scheduler_tasks_idx_checked_out" ON "#__scheduler_tasks" ("checked_out"); + +-- -------------------------------------------------------- + -- -- Here is SOUNDEX replacement for those who can't enable fuzzystrmatch module -- from contrib folder. diff --git a/installation/sql/postgresql/supports.sql b/installation/sql/postgresql/supports.sql index 121a79a1257b2..f9a053ee6e152 100644 --- a/installation/sql/postgresql/supports.sql +++ b/installation/sql/postgresql/supports.sql @@ -447,4 +447,8 @@ INSERT INTO "#__mail_templates" ("template_id", "extension", "language", "subjec ('com_users.registration.user.admin_activated', 'com_users', '', 'COM_USERS_EMAIL_ACTIVATED_BY_ADMIN_ACTIVATION_SUBJECT', 'COM_USERS_EMAIL_ACTIVATED_BY_ADMIN_ACTIVATION_BODY', '', '', '{"tags":["name","sitename","siteurl","username"]}'), ('com_users.registration.admin.verification_request', 'com_users', '', 'COM_USERS_EMAIL_ACTIVATE_WITH_ADMIN_ACTIVATION_SUBJECT', 'COM_USERS_EMAIL_ACTIVATE_WITH_ADMIN_ACTIVATION_BODY', '', '', '{"tags":["name","sitename","email","username","activate"]}'), ('plg_system_privacyconsent.request.reminder', 'plg_system_privacyconsent', '', 'PLG_SYSTEM_PRIVACYCONSENT_EMAIL_REMIND_SUBJECT', 'PLG_SYSTEM_PRIVACYCONSENT_EMAIL_REMIND_BODY', '', '', '{"tags":["sitename","url","tokenurl","formurl","token"]}'), -('com_messages.new_message', 'com_messages', '', 'COM_MESSAGES_NEW_MESSAGE', 'COM_MESSAGES_NEW_MESSAGE_BODY', '', '', '{"tags":["subject","message","fromname","sitename","siteurl","fromemail","toname","toemail"]}'); +('com_messages.new_message', 'com_messages', '', 'COM_MESSAGES_NEW_MESSAGE', 'COM_MESSAGES_NEW_MESSAGE_BODY', '', '', '{"tags":["subject","message","fromname","sitename","siteurl","fromemail","toname","toemail"]}'), +('plg_system_tasknotification.failure_mail', 'plg_system_tasknotification', '', 'PLG_SYSTEM_TASK_NOTIFICATION_FAILURE_MAIL_SUBJECT', 'PLG_SYSTEM_TASK_NOTIFICATION_FAILURE_MAIL_BODY', '', '', '{"tags": ["task_id", "task_title", "exit_code", "exec_data_time", "task_output"]}'), +('plg_system_tasknotification.fatal_recovery_mail', 'plg_system_tasknotification', '', 'PLG_SYSTEM_TASK_NOTIFICATION_FATAL_MAIL_SUBJECT', 'PLG_SYSTEM_TASK_NOTIFICATION_FATAL_MAIL_BODY', '', '', '{"tags": ["task_id", "task_title"]}'), +('plg_system_tasknotification.orphan_mail', 'plg_system_tasknotification', '', 'PLG_SYSTEM_TASK_NOTIFICATION_ORPHAN_MAIL_SUBJECT', 'PLG_SYSTEM_TASK_NOTIFICATION_ORPHAN_MAIL_BODY', '', '', '{"tags": ["task_id", "task_title", ""]}'), +('plg_system_tasknotification.success_mail', 'plg_system_tasknotification', '', 'PLG_SYSTEM_TASK_NOTIFICATION_SUCCESS_MAIL_SUBJECT', 'PLG_SYSTEM_TASK_NOTIFICATION_SUCCESS_MAIL_BODY', '', '', '{"tags":["task_id", "task_title", "exec_data_time", "task_output"]}'); diff --git a/libraries/src/Console/TasksListCommand.php b/libraries/src/Console/TasksListCommand.php new file mode 100644 index 0000000000000..71f6dccaf7927 --- /dev/null +++ b/libraries/src/Console/TasksListCommand.php @@ -0,0 +1,140 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\Console; + +// Restrict direct access +defined('JPATH_PLATFORM') or die; + +use Joomla\CMS\Factory; +use Joomla\Component\Scheduler\Administrator\Scheduler\Scheduler; +use Joomla\Console\Application; +use Joomla\Console\Command\AbstractCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Console command to list scheduled tasks. + * + * @since __DEPLOY_VERSION__ + */ +class TasksListCommand extends AbstractCommand +{ + /** + * The default command name + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected static $defaultName = 'scheduler:list'; + + /** + * The console application object + * + * @var Application + * @since __DEPLOY_VERSION__ + */ + protected $application; + + /** + * @var SymfonyStyle + * @since __DEPLOY_VERSION__ + */ + private $ioStyle; + + + /** + * Internal function to execute the command. + * + * @param InputInterface $input The input to inject into the command. + * @param OutputInterface $output The output to inject into the command. + * + * @return integer The command exit code + * + * @since __DEPLOY_VERSION__ + * @throws \Exception + */ + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + Factory::getApplication()->getLanguage()->load('joomla', JPATH_ADMINISTRATOR); + + $this->configureIO($input, $output); + $this->ioStyle->title('List Scheduled Tasks'); + + $tasks = array_map( + function (\stdClass $task): array { + $enabled = $task->state === 1; + $nextExec = Factory::getDate($task->next_execution, 'UTC'); + $due = $enabled && $task->taskOption && Factory::getDate('now', 'UTC') > $nextExec; + + return [ + 'id' => $task->id, + 'title' => $task->title, + 'type' => $task->safeTypeTitle, + 'state' => $task->state === 1 ? 'Enabled' : ($task->state === 0 ? 'Disabled' : 'Trashed'), + 'next_execution' => $due ? 'DUE!' : $nextExec->toRFC822(), + ]; + }, + $this->getTasks() + ); + + $this->ioStyle->table(['id', 'title', 'type', 'state', 'next run'], $tasks); + + return 0; + } + + /** + * Returns a stdClass object array of scheduled tasks. + * + * @return array + * + * @since __DEPLOY_VERSION__ + * @throws \RunTimeException + */ + private function getTasks(): array + { + $scheduler = new Scheduler; + + return $scheduler->fetchTaskRecords( + ['state' => '*'], + ['ordering' => 'a.title', 'select' => 'a.id, a.title, a.type, a.state, a.next_execution'] + ); + } + + /** + * Configure the IO. + * + * @param InputInterface $input The input to inject into the command. + * @param OutputInterface $output The output to inject into the command. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function configureIO(InputInterface $input, OutputInterface $output) + { + $this->ioStyle = new SymfonyStyle($input, $output); + } + + /** + * Configure the command. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function configure(): void + { + $help = "%command.name% lists all scheduled tasks. + \nUsage: php %command.full_name%"; + + $this->setDescription('List all scheduled tasks'); + $this->setHelp($help); + } +} diff --git a/libraries/src/Console/TasksRunCommand.php b/libraries/src/Console/TasksRunCommand.php new file mode 100644 index 0000000000000..09090c12f714c --- /dev/null +++ b/libraries/src/Console/TasksRunCommand.php @@ -0,0 +1,155 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\Console; + +// Restrict direct access +\defined('JPATH_PLATFORM') or die; + +use Joomla\Component\Scheduler\Administrator\Scheduler\Scheduler; +use Joomla\Component\Scheduler\Administrator\Task\Status; +use Joomla\Console\Command\AbstractCommand; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Console command to run scheduled tasks. + * + * @since __DEPLOY_VERSION__ + */ +class TasksRunCommand extends AbstractCommand +{ + /** + * The default command name + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected static $defaultName = 'scheduler:run'; + + /** + * @var SymfonyStyle + * @since __DEPLOY_VERSION__ + */ + private $ioStyle; + + /** + * @param InputInterface $input The input to inject into the command. + * @param OutputInterface $output The output to inject into the command. + * + * @return integer The command exit code. + * + * @since __DEPLOY_VERSION__ + * @throws \RunTimeException + * @throws InvalidArgumentException + */ + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + /** + * Not as a class constant because of some the autoload order doesn't let us + * load the namespace when it's time to do that (why?) + */ + static $outTextMap = [ + Status::OK => 'Task#%1$02d \'%2$s\' processed in %3$.2f seconds.', + Status::NO_RUN => 'Task#%1$02d \'%2$s\' failed to run. Is it already running?', + Status::NO_ROUTINE => 'Task#%1$02d \'%2$s\' is orphaned! Visit the backend to resolve.', + 'N/A' => 'Task#%1$02d \'%2$s\' exited with code %4$d in %3$.2f seconds.', + ]; + + $this->configureIo($input, $output); + $this->ioStyle->title('Run tasks'); + + $scheduler = new Scheduler; + + $id = $input->getOption('id'); + $all = $input->getOption('all'); + + if ($id) + { + $records[] = $scheduler->fetchTaskRecord($id); + } + else + { + $filters = $scheduler::TASK_QUEUE_FILTERS; + $listConfig = $scheduler::TASK_QUEUE_LIST_CONFIG; + $listConfig['limit'] = ($all ? null : 1); + + $records = $scheduler->fetchTaskRecords($filters, $listConfig); + } + + if ($id && !$records[0]) + { + $this->ioStyle->writeln('No matching task found!'); + + return Status::NO_TASK; + } + elseif (!$records) + { + $this->ioStyle->writeln('No tasks due!'); + + return Status::NO_TASK; + } + + $status = ['startTime' => microtime(true)]; + $taskCount = \count($records); + $exit = Status::OK; + + foreach ($records as $record) + { + $cStart = microtime(true); + $task = $scheduler->runTask(['id' => $record->id, 'allowDisabled' => true, 'allowConcurrent' => true]); + $exit = empty($task) ? Status::NO_RUN : $task->getContent()['status']; + $duration = microtime(true) - $cStart; + $key = (\array_key_exists($exit, $outTextMap)) ? $exit : 'N/A'; + $this->ioStyle->writeln(sprintf($outTextMap[$key], $record->id, $record->title, $duration, $exit)); + } + + $netTime = round(microtime(true) - $status['startTime'], 2); + $this->ioStyle->newLine(); + $this->ioStyle->writeln("Finished running $taskCount tasks in $netTime seconds."); + + return $taskCount === 1 ? $exit : Status::OK; + } + + /** + * Configure the IO. + * + * @param InputInterface $input The input to inject into the command. + * @param OutputInterface $output The output to inject into the command. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function configureIO(InputInterface $input, OutputInterface $output) + { + $this->ioStyle = new SymfonyStyle($input, $output); + } + + /** + * Configure the command. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function configure(): void + { + $this->addOption('id', 'i', InputOption::VALUE_REQUIRED, 'The id of the task to run.'); + $this->addOption('all', '', InputOption::VALUE_NONE, 'Run all due tasks. Note that this is overridden if --id is used.'); + + $help = "%command.name% run scheduled tasks. + \nUsage: php %command.full_name% [flags]"; + + $this->setDescription('Run one or more scheduled tasks'); + $this->setHelp($help); + } +} diff --git a/libraries/src/Console/TasksStateCommand.php b/libraries/src/Console/TasksStateCommand.php new file mode 100644 index 0000000000000..aaf7dafb01334 --- /dev/null +++ b/libraries/src/Console/TasksStateCommand.php @@ -0,0 +1,201 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\CMS\Console; + +// Restrict direct access +\defined('JPATH_PLATFORM') or die; + +use Joomla\CMS\Application\ConsoleApplication; +use Joomla\CMS\Factory; +use Joomla\Component\Jobs\Administrator\Table\TaskTable; +use Joomla\Component\Scheduler\Administrator\Model\TaskModel; +use Joomla\Component\Scheduler\Administrator\Task\Task; +use Joomla\Console\Application; +use Joomla\Console\Command\AbstractCommand; +use Joomla\Utilities\ArrayHelper; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Console command to change the state of tasks. + * + * @since __DEPLOY_VERSION__ + */ +class TasksStateCommand extends AbstractCommand +{ + /** + * The default command name + * + * @var string + * @since __DEPLOY_VERSION__ + */ + protected static $defaultName = 'scheduler:state'; + + /** + * The console application object + * + * @var Application + * + * @since __DEPLOY_VERSION__ + */ + protected $application; + + /** + * @var SymfonyStyle + * + * @since __DEPLOY_VERSION__ + */ + private $ioStyle; + + /** + * Internal function to execute the command. + * + * @param InputInterface $input The input to inject into the command. + * @param OutputInterface $output The output to inject into the command. + * + * @return integer The command exit code + * + * @since __DEPLOY_VERSION__ + * @throws \Exception + */ + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + Factory::getApplication()->getLanguage()->load('joomla', JPATH_ADMINISTRATOR); + + $this->configureIO($input, $output); + + $id = (string) $input->getOption('id'); + $state = (string) $input->getOption('state'); + + // Try to validate and process ID, if passed + if (\strlen($id)) + { + if (!Task::isValidId($id)) + { + $this->ioStyle->error('Invalid id passed!'); + + return 2; + } + + $id = (is_numeric($id)) ? ($id + 0) : $id; + } + + // Try to validate and process state, if passed + if (\strlen($state)) + { + // If we get the logical state, we try to get the enumeration (but as a string) + if (!is_numeric($state)) + { + $state = (string) ArrayHelper::arraySearch($state, Task::STATE_MAP); + } + + if (!\strlen($state) || !Task::isValidState($state)) + { + $this->ioStyle->error('Invalid state passed!'); + + return 2; + } + } + + // If we didn't get ID as a flag, ask for it interactively + while (!Task::isValidId($id)) + { + $id = $this->ioStyle->ask('Please specify the ID of the task'); + } + + // If we didn't get state as a flag, ask for it interactively + while ($state === false || !Task::isValidState($state)) + { + $state = (string) $this->ioStyle->ask('Should the state be "enable" (1), "disable" (0) or "trash" (-2)'); + + // Ensure we have the enumerated value (still as a string) + $state = (Task::isValidState($state)) ?: ArrayHelper::arraySearch($state, Task::STATE_MAP); + } + + // Finally, the enumerated state and id in their pure form + $state = (int) $state; + $id = (int) $id; + + /** @var ConsoleApplication $app */ + $app = $this->getApplication(); + + /** @var TaskModel $taskModel */ + $taskModel = $app->bootComponent('com_scheduler')->getMVCFactory()->createModel('Task', 'Administrator'); + + $task = $taskModel->getItem($id); + + // We couldn't fetch that task :( + if (empty($task->id)) + { + $this->ioStyle->error("Task ID '${id}' does not exist!"); + + return 1; + } + + // If the item is checked-out we need a check in (currently not possible through the CLI) + if ($taskModel->isCheckedOut($task)) + { + $this->ioStyle->error("Task ID '${id}' is checked out!"); + + return 1; + } + + /** @var TaskTable $table */ + $table = $taskModel->getTable(); + + $action = Task::STATE_MAP[$state]; + + if (!$table->publish($id, $state)) + { + $this->ioStyle->error("Can't ${action} Task ID '${id}'"); + + return 3; + } + + $this->ioStyle->success("Task ID ${id} ${action}."); + + return 0; + } + + /** + * Configure the IO. + * + * @param InputInterface $input The input to inject into the command. + * @param OutputInterface $output The output to inject into the command. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + private function configureIO(InputInterface $input, OutputInterface $output): void + { + $this->ioStyle = new SymfonyStyle($input, $output); + } + + /** + * Configure the command. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + protected function configure(): void + { + $this->addOption('id', 'i', InputOption::VALUE_REQUIRED, 'The id of the task to change state.'); + $this->addOption('state', 's', InputOption::VALUE_REQUIRED, 'The new state of the task, can be 1/enable, 0/disable, or -2/trash.'); + + $help = "%command.name% changes the state of a task. + \nUsage: php %command.full_name%"; + + $this->setDescription('Enable, disable or trash a scheduled task'); + $this->setHelp($help); + } +} diff --git a/libraries/src/Extension/ExtensionHelper.php b/libraries/src/Extension/ExtensionHelper.php index a13bcf45985ac..dac98d8f63263 100644 --- a/libraries/src/Extension/ExtensionHelper.php +++ b/libraries/src/Extension/ExtensionHelper.php @@ -79,6 +79,7 @@ class ExtensionHelper array('component', 'com_postinstall', '', 1), array('component', 'com_privacy', '', 1), array('component', 'com_redirect', '', 1), + array('component', 'com_scheduler', '', 1), array('component', 'com_tags', '', 1), array('component', 'com_templates', '', 1), array('component', 'com_users', '', 1), @@ -283,13 +284,21 @@ class ExtensionHelper array('plugin', 'privacyconsent', 'system', 0), array('plugin', 'redirect', 'system', 0), array('plugin', 'remember', 'system', 0), + array('plugin', 'schedulerunner', 'system', 0), array('plugin', 'sef', 'system', 0), array('plugin', 'sessiongc', 'system', 0), array('plugin', 'skipto', 'system', 0), array('plugin', 'stats', 'system', 0), + array('plugin', 'tasknotification', 'system', 0), array('plugin', 'updatenotification', 'system', 0), array('plugin', 'webauthn', 'system', 0), + // Core plugin extensions - task scheduler + array('plugin', 'checkfiles', 'task', 0), + array('plugin', 'demotasks', 'task', 0), + array('plugin', 'requests', 'task', 0), + array('plugin', 'sitestatus', 'task', 0), + // Core plugin extensions - two factor authentication array('plugin', 'totp', 'twofactorauth', 0), array('plugin', 'yubikey', 'twofactorauth', 0), diff --git a/libraries/src/Service/Provider/Application.php b/libraries/src/Service/Provider/Application.php index 24ba5cdd4d099..d9dac4adababa 100644 --- a/libraries/src/Service/Provider/Application.php +++ b/libraries/src/Service/Provider/Application.php @@ -15,10 +15,10 @@ use Joomla\CMS\Application\ConsoleApplication; use Joomla\CMS\Application\SiteApplication; use Joomla\CMS\Console\CheckJoomlaUpdatesCommand; -use Joomla\CMS\Console\ExtensionInstallCommand; use Joomla\CMS\Console\ExtensionDiscoverCommand; use Joomla\CMS\Console\ExtensionDiscoverInstallCommand; use Joomla\CMS\Console\ExtensionDiscoverListCommand; +use Joomla\CMS\Console\ExtensionInstallCommand; use Joomla\CMS\Console\ExtensionRemoveCommand; use Joomla\CMS\Console\ExtensionsListCommand; use Joomla\CMS\Console\FinderIndexCommand; @@ -30,6 +30,9 @@ use Joomla\CMS\Console\SetConfigurationCommand; use Joomla\CMS\Console\SiteDownCommand; use Joomla\CMS\Console\SiteUpCommand; +use Joomla\CMS\Console\TasksListCommand; +use Joomla\CMS\Console\TasksRunCommand; +use Joomla\CMS\Console\TasksStateCommand; use Joomla\CMS\Console\UpdateCoreCommand; use Joomla\CMS\Factory; use Joomla\CMS\Language\LanguageFactoryInterface; @@ -147,23 +150,26 @@ function (Container $container) function (Container $container) { $mapping = [ - SessionGcCommand::getDefaultName() => SessionGcCommand::class, - SessionMetadataGcCommand::getDefaultName() => SessionMetadataGcCommand::class, - ExportCommand::getDefaultName() => ExportCommand::class, - ImportCommand::getDefaultName() => ImportCommand::class, - SiteDownCommand::getDefaultName() => SiteDownCommand::class, - SiteUpCommand::getDefaultName() => SiteUpCommand::class, - SetConfigurationCommand::getDefaultName() => SetConfigurationCommand::class, - GetConfigurationCommand::getDefaultName() => GetConfigurationCommand::class, - ExtensionsListCommand::getDefaultName() => ExtensionsListCommand::class, - CheckJoomlaUpdatesCommand::getDefaultName() => CheckJoomlaUpdatesCommand::class, - ExtensionRemoveCommand::getDefaultName() => ExtensionRemoveCommand::class, - ExtensionInstallCommand::getDefaultName() => ExtensionInstallCommand::class, - ExtensionDiscoverCommand::getDefaultName() => ExtensionDiscoverCommand::class, - ExtensionDiscoverInstallCommand::getDefaultName() => ExtensionDiscoverInstallCommand::class, - ExtensionDiscoverListCommand::getDefaultName() => ExtensionDiscoverListCommand::class, - UpdateCoreCommand::getDefaultName() => UpdateCoreCommand::class, - FinderIndexCommand::getDefaultName() => FinderIndexCommand::class, + SessionGcCommand::getDefaultName() => SessionGcCommand::class, + SessionMetadataGcCommand::getDefaultName() => SessionMetadataGcCommand::class, + ExportCommand::getDefaultName() => ExportCommand::class, + ImportCommand::getDefaultName() => ImportCommand::class, + SiteDownCommand::getDefaultName() => SiteDownCommand::class, + SiteUpCommand::getDefaultName() => SiteUpCommand::class, + SetConfigurationCommand::getDefaultName() => SetConfigurationCommand::class, + GetConfigurationCommand::getDefaultName() => GetConfigurationCommand::class, + ExtensionsListCommand::getDefaultName() => ExtensionsListCommand::class, + CheckJoomlaUpdatesCommand::getDefaultName() => CheckJoomlaUpdatesCommand::class, + ExtensionRemoveCommand::getDefaultName() => ExtensionRemoveCommand::class, + ExtensionInstallCommand::getDefaultName() => ExtensionInstallCommand::class, + ExtensionDiscoverCommand::getDefaultName() => ExtensionDiscoverCommand::class, + ExtensionDiscoverInstallCommand::getDefaultName() => ExtensionDiscoverInstallCommand::class, + ExtensionDiscoverListCommand::getDefaultName() => ExtensionDiscoverListCommand::class, + UpdateCoreCommand::getDefaultName() => UpdateCoreCommand::class, + FinderIndexCommand::getDefaultName() => FinderIndexCommand::class, + TasksListCommand::getDefaultName() => TasksListCommand::class, + TasksRunCommand::getDefaultName() => TasksRunCommand::class, + TasksStateCommand::getDefaultName() => TasksStateCommand::class, ]; return new WritableContainerLoader($container, $mapping); diff --git a/libraries/src/Service/Provider/Console.php b/libraries/src/Service/Provider/Console.php index e3ac6823daeee..797a07f0d7b1c 100644 --- a/libraries/src/Service/Provider/Console.php +++ b/libraries/src/Service/Provider/Console.php @@ -11,10 +11,10 @@ \defined('JPATH_PLATFORM') or die; use Joomla\CMS\Console\CheckJoomlaUpdatesCommand; -use Joomla\CMS\Console\ExtensionInstallCommand; use Joomla\CMS\Console\ExtensionDiscoverCommand; use Joomla\CMS\Console\ExtensionDiscoverInstallCommand; use Joomla\CMS\Console\ExtensionDiscoverListCommand; +use Joomla\CMS\Console\ExtensionInstallCommand; use Joomla\CMS\Console\ExtensionRemoveCommand; use Joomla\CMS\Console\ExtensionsListCommand; use Joomla\CMS\Console\FinderIndexCommand; @@ -24,6 +24,9 @@ use Joomla\CMS\Console\SetConfigurationCommand; use Joomla\CMS\Console\SiteDownCommand; use Joomla\CMS\Console\SiteUpCommand; +use Joomla\CMS\Console\TasksListCommand; +use Joomla\CMS\Console\TasksRunCommand; +use Joomla\CMS\Console\TasksStateCommand; use Joomla\CMS\Console\UpdateCoreCommand; use Joomla\CMS\Session\MetadataManager; use Joomla\Database\Command\ExportCommand; @@ -208,5 +211,30 @@ function (Container $container) }, true ); + + $container->share( + TasksListCommand::class, + function (Container $container) + { + return new TasksListCommand; + }, + true + ); + + $container->share( + TasksRunCommand::class, + function (Container $container) + { + return new TasksRunCommand; + } + ); + + $container->share( + TasksStateCommand::class, + function (Container $container) + { + return new TasksStateCommand; + } + ); } } diff --git a/plugins/system/schedulerunner/schedulerunner.php b/plugins/system/schedulerunner/schedulerunner.php new file mode 100644 index 0000000000000..dfea6eb541846 --- /dev/null +++ b/plugins/system/schedulerunner/schedulerunner.php @@ -0,0 +1,389 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Restrict direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Component\ComponentHelper; +use Joomla\CMS\Factory; +use Joomla\CMS\Form\Form; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Log\Log; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Table\Extension; +use Joomla\CMS\User\UserHelper; +use Joomla\Component\Scheduler\Administrator\Scheduler\Scheduler; +use Joomla\Component\Scheduler\Administrator\Task\Task; +use Joomla\Event\Event; +use Joomla\Event\EventInterface; +use Joomla\Event\SubscriberInterface; +use Joomla\Registry\Registry; + +/** + * This plugin implements listeners to support a visitor-triggered lazy-scheduling pattern. + * If `com_scheduler` is installed/enabled and its configuration allows unprotected lazy scheduling, this plugin + * injects into each response with an HTML context a JS file {@see PlgSystemSchedulerunner::injectScheduleRunner()} that + * sets up an AJAX callback to trigger the scheduler {@see PlgSystemSchedulerunner::runScheduler()}. This is achieved + * through a call to the `com_ajax` component. + * Also supports the scheduler component configuration form through auto-generation of the webcron key and injection + * of JS of usability enhancement. + * + * @since __DEPLOY_VERSION__ + */ +class PlgSystemSchedulerunner extends CMSPlugin implements SubscriberInterface +{ + /** + * Length of auto-generated webcron key. + * + * @var integer + * @since __DEPLOY_VERSION__ + */ + private const WEBCRON_KEY_LENGTH = 20; + + /** + * @var CMSApplication + * @since __DEPLOY_VERSION__ + */ + protected $app; + + /** + * @inheritDoc + * + * @return string[] + * + * @since __DEPLOY_VERSION__ + * + * @throws Exception + */ + public static function getSubscribedEvents(): array + { + $config = ComponentHelper::getParams('com_scheduler'); + $app = Factory::getApplication(); + + $mapping = []; + + if ($app->isClient('site') || $app->isClient('administrator')) + { + $mapping['onBeforeCompileHead'] = 'injectLazyJS'; + $mapping['onAjaxRunSchedulerLazy'] = 'runLazyCron'; + + // Only allowed in the frontend + if ($app->isClient('site')) + { + if ($config->get('webcron.enabled')) + { + $mapping['onAjaxRunSchedulerWebcron'] = 'runWebCron'; + } + } + elseif ($app->isClient('administrator')) + { + $mapping['onContentPrepareForm'] = 'enhanceSchedulerConfig'; + $mapping['onExtensionBeforeSave'] = 'generateWebcronKey'; + + $mapping['onAjaxRunSchedulerTest'] = 'runTestCron'; + } + } + + return $mapping; + } + + /** + * Inject JavaScript to trigger the scheduler in HTML contexts. + * + * @param EventInterface $event The onBeforeCompileHead event. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function injectLazyJS(EventInterface $event): void + { + // Only inject in HTML documents + if ($this->app->getDocument()->getType() !== 'html') + { + return; + } + + $config = ComponentHelper::getParams('com_scheduler'); + + if (!$config->get('lazy_scheduler.enabled', true)) + { + return; + } + + // Check if any task is due to decrease the load + $model = $this->app->bootComponent('com_scheduler') + ->getMVCFactory()->createModel('Tasks', 'Administrator', ['ignore_request' => true]); + + $model->setState('filter.state', 1); + $model->setState('filter.due', 1); + + $items = $model->getItems(); + + // See if we are running currently + $model->setState('filter.locked', 1); + $model->setState('filter.due', 0); + + $items2 = $model->getItems(); + + if (empty($items) || !empty($items2)) + { + return; + } + + // Add configuration options + $triggerInterval = $config->get('lazy_scheduler.interval', 300); + $this->app->getDocument()->addScriptOptions('plg_system_schedulerunner', ['interval' => $triggerInterval]); + + // Load and injection directive + $wa = $this->app->getDocument()->getWebAssetManager(); + $wa->getRegistry()->addExtensionRegistryFile('plg_system_schedulerunner'); + $wa->useScript('plg_system_schedulerunner.run-schedule'); + } + + /** + * Acts on the LazyCron trigger from the frontend when Lazy Cron is enabled in the Scheduler component + * configuration. The lazy cron trigger is implemented in client-side JavaScript which is injected on every page + * load with an HTML context when the component configuration allows it. This method then triggers the Scheduler, + * which effectively runs the next Task in the Scheduler's task queue. + * + * @param EventInterface $e The onAjaxRunSchedulerLazy event. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * + * @throws Exception + */ + public function runLazyCron(EventInterface $e) + { + $config = ComponentHelper::getParams('com_scheduler'); + + if (!$config->get('lazy_scheduler.enabled', true)) + { + return; + } + + // Since `navigator.sendBeacon()` may time out, allow execution after disconnect if possible. + if (function_exists('ignore_user_abort')) + { + ignore_user_abort(true); + } + + // Prevent PHP from trying to output to the user pipe. PHP may kill the script otherwise if the pipe is not accessible. + ob_start(); + + // Suppress all errors to avoid any output + try + { + $this->runScheduler(); + } + catch (Exception $e) + { + } + + ob_end_clean(); + } + + /** + * This method is responsible for the WebCron functionality of the Scheduler component.
+ * Acting on a `com_ajax` call, this method can work in two ways: + * 1. If no Task ID is specified, it triggers the Scheduler to run the next task in + * the task queue. + * 2. If a Task ID is specified, it fetches the task (if it exists) from the Scheduler API and executes it.
+ * + * URL query parameters: + * - `hash` string (required) Webcron hash (from the Scheduler component configuration). + * - `id` int (optional) ID of the task to trigger. + * + * @param Event $event The onAjaxRunSchedulerWebcron event. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * + * @throws Exception + */ + public function runWebCron(Event $event) + { + $config = ComponentHelper::getParams('com_scheduler'); + $hash = $config->get('webcron.key', ''); + + if (!$config->get('webcron.enabled', false)) + { + Log::add(Text::_('PLG_SYSTEM_SCHEDULE_RUNNER_WEBCRON_DISABLED')); + throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + if (!strlen($hash) || $hash !== $this->app->input->get('hash')) + { + throw new Exception(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + $id = (int) $this->app->input->getInt('id', 0); + + $task = $this->runScheduler($id); + + if (!empty($task) && !empty($task->getContent()['exception'])) + { + throw $task->getContent()['exception']; + } + } + + /** + * This method is responsible for the "test run" functionality in the Scheduler administrator backend interface. + * Acting on a `com_ajax` call, this method requires the URL to have a `id` query parameter (corresponding to an + * existing Task ID). + * + * @param Event $event The onAjaxRunScheduler event. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * + * @throws Exception + */ + public function runTestCron(Event $event) + { + $id = (int) $this->app->input->getInt('id'); + $allowConcurrent = $this->app->input->getBool('allowConcurrent', false); + + $user = Factory::getApplication()->getIdentity(); + + if (empty($id) || !$user->authorise('core.testrun', 'com_scheduler.task.' . $id)) + { + throw new \Exception(Text::_('JERROR_ALERTNOAUTHOR'), 403); + } + + /** + * ?: About allow simultaneous, how do we detect if it failed because of pre-existing lock? + * + * We will allow CLI exclusive tasks to be fetched and executed, it's left to routines to do a runtime check + * if they want to refuse normal operation. + */ + $task = (new Scheduler)->getTask( + [ + 'id' => $id, + 'allowDisabled' => true, + 'bypassScheduling' => true, + 'allowConcurrent' => $allowConcurrent, + ] + ); + + if (!is_null($task)) + { + $task->run(); + $event->addArgument('result', $task->getContent()); + } + + else + { + /** + * Placeholder result, but the idea is if we failed to fetch the task, it's likely because another task was + * already running. This is a fair assumption if this test run was triggered through the administrator backend, + * so we know the task probably exists and is either enabled/disabled (not trashed). + */ + // @todo language constant + review if this is done right. + $event->addArgument('result', ['message' => 'could not acquire lock on task. retry or allow concurrency.']); + } + } + + /** + * Run the scheduler, allowing execution of a single due task. + * Does not bypass task scheduling, meaning that even if an ID is passed the task is only + * triggered if it is due. + * + * @param integer $id The optional ID of the task to run + * + * @return ?Task + * + * @since __DEPLOY_VERSION__ + * @throws RuntimeException + */ + protected function runScheduler(int $id = 0): ?Task + { + return (new Scheduler)->runTask(['id' => $id]); + } + + /** + * Enhance the scheduler config form by dynamically populating or removing display fields. + * + * @param EventInterface $event The onContentPrepareForm event. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * @throws UnexpectedValueException|RuntimeException + * + * @todo Move to another plugin? + */ + public function enhanceSchedulerConfig(EventInterface $event): void + { + /** @var Form $form */ + $form = $event->getArgument('0'); + $data = $event->getArgument('1'); + + if ($form->getName() !== 'com_config.component' + || $this->app->input->get('component') !== 'com_scheduler') + { + return; + } + + if (!empty($data['webcron']['key'])) + { + $form->removeField('generate_key_on_save', 'webcron'); + + $relative = 'index.php?option=com_ajax&plugin=RunSchedulerWebcron&group=system&format=json&hash=' . $data['webcron']['key']; + $link = Route::link('site', $relative, false, Route::TLS_IGNORE, true); + $form->setValue('base_link', 'webcron', $link); + } + else + { + $form->removeField('base_link', 'webcron'); + $form->removeField('reset_key', 'webcron'); + } + } + + /** + * Auto-generate a key/hash for the webcron functionality. + * This method acts on table save, when a hash doesn't already exist or a reset is required. + * @todo Move to another plugin? + * + * @param EventInterface $event The onExtensionBeforeSave event. + * + * @return void + * + * @since __DEPLOY_VERSION__ + */ + public function generateWebcronKey(EventInterface $event): void + { + /** @var Extension $table */ + [$context, $table] = $event->getArguments(); + + if ($context !== 'com_config.component' + || ($table->name ?? '') !== 'COM_SCHEDULER') + { + return; + } + + $params = new Registry($table->params ?? ''); + + if (empty($params->get('webcron.key')) + || $params->get('webcron.reset_key') === 1) + { + $params->set('webcron.key', UserHelper::genRandomPassword(self::WEBCRON_KEY_LENGTH)); + } + + $params->remove('webcron.base_link'); + $params->remove('webcron.reset_key'); + $table->params = $params->toString(); + } +} diff --git a/plugins/system/schedulerunner/schedulerunner.xml b/plugins/system/schedulerunner/schedulerunner.xml new file mode 100644 index 0000000000000..9c5f0607d64c5 --- /dev/null +++ b/plugins/system/schedulerunner/schedulerunner.xml @@ -0,0 +1,23 @@ + + + PLG_SYSTEM_SCHEDULERUNNER + Joomla! Project + August 2021 + (C) 2021 Open Source Matters, Inc. + GNU General Public License version 2 or later; see LICENSE.txt + admin@joomla.org + www.joomla.org + 4.1 + PLG_SYSTEM_SCHEDULERUNNER_XML_DESCRIPTION + + js + joomla.asset.json + + + schedulerunner.php + + + language/en-GB/plg_system_schedulerunner.ini + language/en-GB/plg_system_schedulerunner.sys.ini + + diff --git a/plugins/system/tasknotification/forms/task_notification.xml b/plugins/system/tasknotification/forms/task_notification.xml new file mode 100644 index 0000000000000..45f4d2dd38a5b --- /dev/null +++ b/plugins/system/tasknotification/forms/task_notification.xml @@ -0,0 +1,49 @@ + +
+ + +
+ + + + + + + + + + + + + + + + +
+
+
+
diff --git a/plugins/system/tasknotification/language/en-GB/plg_system_tasknotification.ini b/plugins/system/tasknotification/language/en-GB/plg_system_tasknotification.ini new file mode 100644 index 0000000000000..ba7edac52e748 --- /dev/null +++ b/plugins/system/tasknotification/language/en-GB/plg_system_tasknotification.ini @@ -0,0 +1,16 @@ +PLG_SYSTEM_TASK_NOTIFICATION="System - Task Notification" +PLG_SYSTEM_TASK_NOTIFICATION_FAILURE_MAIL_BODY="Hello,\n\n\nPlanned execution of Scheduled Task#{TASK_ID}, {TASK_TITLE}, has failed with exit code {EXIT_CODE} at {EXEC_DATE_TIME}.\n\nPlease visit the Joomla! backend for more information.\n\n{TASK_OUTPUT}" +PLG_SYSTEM_TASK_NOTIFICATION_FAILURE_MAIL_SUBJECT="Task Failure" +PLG_SYSTEM_TASK_NOTIFICATION_FATAL_MAIL_BODY="Hello,\n\nPlanned execution of Scheduler Task#{TASK_ID}, {TASK_TITLE}, recovered from a fatal failure.\n\nThis could mean that the task execution exhausted the system resources or the restrictions from the PHP INI.\n\nPlease visit the Joomla! backend for more information." +PLG_SYSTEM_TASK_NOTIFICATION_FATAL_MAIL_SUBJECT="Task Recover from Fatal Failure" +PLG_SYSTEM_TASK_NOTIFICATION_LABEL_FAILURE_MAIL_TOGGLE="Notifications on Task Failure" +PLG_SYSTEM_TASK_NOTIFICATION_LABEL_FATAL_FAILURE_MAIL_TOGGLE="Notifications on Fatal Failures/Crashes (Recommended)" +PLG_SYSTEM_TASK_NOTIFICATION_LABEL_ORPHANED_TASK_MAIL_TOGGLE="Notifications on Orphaned Tasks (Recommended)" +PLG_SYSTEM_TASK_NOTIFICATION_LABEL_SUCCESS_MAIL_TOGGLE="Notifications on Task Success" +PLG_SYSTEM_TASK_NOTIFICATION_NO_MAIL_SENT="Could not send task notification to any user. This either means that mailer is not set up properly or no user with system emails enabled, com_scheduler `core.manage` privilege exists." +PLG_SYSTEM_TASK_NOTIFICATION_ORPHAN_MAIL_BODY="Hello,\n\nScheduled Task#{TASK_ID}, {TASK_TITLE}, has been orphaned. This likely means that the provider plugin was removed or disabled from your Joomla! installation.\n\nPlease visit the Joomla! backend to investigate." +PLG_SYSTEM_TASK_NOTIFICATION_ORPHAN_MAIL_SUBJECT="New Orphaned Task" +PLG_SYSTEM_TASK_NOTIFICATION_SUCCESS_MAIL_BODY="Hello,\n\nScheduled Task#{TASK_ID}, {TASK_TITLE}, has been successfully executed at {EXEC_DATE_TIME}.\n\n{TASK_OUTPUT}" +PLG_SYSTEM_TASK_NOTIFICATION_SUCCESS_MAIL_SUBJECT="Task Successful" +PLG_SYSTEM_TASK_NOTIFICATION_USER_FETCH_FAIL="Failed to fetch users to send notifications to." +PLG_SYSTEM_TASK_NOTIFICATION_XML_DESCRIPTION="Responsible for email notifications for execution of Scheduled tasks." diff --git a/plugins/system/tasknotification/language/en-GB/plg_system_tasknotification.sys.ini b/plugins/system/tasknotification/language/en-GB/plg_system_tasknotification.sys.ini new file mode 100644 index 0000000000000..dffb2ef6e97f2 --- /dev/null +++ b/plugins/system/tasknotification/language/en-GB/plg_system_tasknotification.sys.ini @@ -0,0 +1,2 @@ +PLG_SYSTEM_TASK_NOTIFICATION="System - Task Notification" +PLG_SYSTEM_TASK_NOTIFICATION_XML_DESCRIPTION="Responsible for email notifications for execution of Scheduled tasks." diff --git a/plugins/system/tasknotification/tasknotification.php b/plugins/system/tasknotification/tasknotification.php new file mode 100644 index 0000000000000..31798a0f141b5 --- /dev/null +++ b/plugins/system/tasknotification/tasknotification.php @@ -0,0 +1,339 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Restrict direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Factory; +use Joomla\CMS\Filesystem\File; +use Joomla\CMS\Filesystem\Path; +use Joomla\CMS\Form\Form; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Log\Log; +use Joomla\CMS\Mail\MailTemplate; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Component\Scheduler\Administrator\Task\Status; +use Joomla\Component\Scheduler\Administrator\Task\Task; +use Joomla\Database\DatabaseInterface; +use Joomla\Event\Event; +use Joomla\Event\EventInterface; +use Joomla\Event\SubscriberInterface; +use PHPMailer\PHPMailer\Exception as MailerException; + +/** + * This plugin implements email notification functionality for Tasks configured through the Scheduler component. + * Notification configuration is supported on a per-task basis, which can be set-up through the Task item form, made + * possible by injecting the notification fields into the item form with a `onContentPrepareForm` listener.
+ * + * Notifications can be set-up on: task success, failure, fatal failure (task running too long or crashing the request), + * or on _orphaned_ task routines (missing parent plugin - either uninstalled, disabled or no longer offering a routine + * with the same ID). + * + * @since __DEPLOY_VERSION__ + */ +class PlgSystemTasknotification extends CMSPlugin implements SubscriberInterface +{ + /** + * The task notification form. This form is merged into the task item form by {@see + * injectTaskNotificationFieldset()}. + * + * @var string + * @since __DEPLOY_VERSION__ + */ + private const TASK_NOTIFICATION_FORM = 'task_notification'; + + /** + * @var CMSApplication + * @since __DEPLOY_VERSION__ + */ + protected $app; + + /** + * @var DatabaseInterface + * @since __DEPLOY_VERSION__ + */ + protected $db; + + /** + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected $autoloadLanguage = true; + + + /** + * @inheritDoc + * + * @return array + * + * @since __DEPLOY_VERSION__ + */ + public static function getSubscribedEvents(): array + { + return [ + 'onContentPrepareForm' => 'injectTaskNotificationFieldset', + 'onTaskExecuteSuccess' => 'notifySuccess', + 'onTaskExecuteFailure' => 'notifyFailure', + 'onTaskRoutineNotFound' => 'notifyOrphan', + 'onTaskRecoverFailure' => 'notifyFatalRecovery', + ]; + } + + /** + * Inject fields to support configuration of post-execution notifications into the task item form. + * + * @param EventInterface $event The onContentPrepareForm event. + * + * @return boolean True if successful. + * + * @since __DEPLOY_VERSION__ + */ + public function injectTaskNotificationFieldset(EventInterface $event): bool + { + /** @var Form $form */ + $form = $event->getArgument('0'); + + if ($form->getName() !== 'com_scheduler.task') + { + return true; + } + + $formFile = __DIR__ . "/forms/" . self::TASK_NOTIFICATION_FORM . '.xml'; + + try + { + $formFile = Path::check($formFile); + } + catch (Exception $e) + { + // Log? + return false; + } + + if (!File::exists($formFile)) + { + return false; + } + + return $form->loadFile($formFile); + } + + /** + * Send out email notifications on Task execution failure if task configuration allows it. + * + * @param Event $event The onTaskExecuteFailure event. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + public function notifyFailure(Event $event): void + { + /** @var Task $task */ + $task = $event->getArgument('subject'); + + if (!(int) $task->get('params.notifications.failure_mail', 1)) + { + return; + } + + // @todo safety checks, multiple files [?] + $outFile = $event->getArgument('subject')->snapshot['output_file'] ?? ''; + $data = $this->getDataFromTask($event->getArgument('subject')); + $this->sendMail('plg_system_tasknotification.failure_mail', $data, $outFile); + } + + /** + * Send out email notifications on orphaned task if task configuration allows.
+ * A task is `orphaned` if the task's parent plugin has been removed/disabled, or no longer offers a task + * with the same routine ID. + * + * @param Event $event The onTaskRoutineNotFound event. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + public function notifyOrphan(Event $event): void + { + /** @var Task $task */ + $task = $event->getArgument('subject'); + + if (!(int) $task->get('params.notifications.orphan_mail', 1)) + { + return; + } + + $data = $this->getDataFromTask($event->getArgument('subject')); + $this->sendMail('plg_system_tasknotification.orphan_mail', $data); + } + + /** + * Send out email notifications on Task execution success if task configuration allows. + * + * @param Event $event The onTaskExecuteSuccess event. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + public function notifySuccess(Event $event): void + { + /** @var Task $task */ + $task = $event->getArgument('subject'); + + if (!(int) $task->get('params.notifications.success_mail', 0)) + { + return; + } + + // @todo safety checks, multiple files [?] + $outFile = $event->getArgument('subject')->snapshot['output_file'] ?? ''; + $data = $this->getDataFromTask($event->getArgument('subject')); + $this->sendMail('plg_system_tasknotification.success_mail', $data, $outFile); + } + + /** + * Send out email notifications on fatal recovery of task execution if task configuration allows.
+ * Fatal recovery indicated that the task either crashed the parent process or its execution lasted longer + * than the global task timeout (this is configurable through the Scheduler component configuration). + * In the latter case, the global task timeout should be adjusted so that this false positive can be avoided. + * This stands as a limitation of the Scheduler's current task execution implementation, which doesn't involve + * keeping track of the parent PHP process which could enable keeping track of the task's status. + * + * @param Event $event The onTaskRecoverFailure event. + * + * @return void + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + public function notifyFatalRecovery(Event $event): void + { + /** @var Task $task */ + $task = $event->getArgument('subject'); + + if (!(int) $task->get('params.notifications.fatal_failure_mail', 1)) + { + return; + } + + $data = $this->getDataFromTask($event->getArgument('subject')); + $this->sendMail('plg_system_tasknotification.fatal_recovery_mail', $data); + } + + /** + * @param Task $task A task object + * + * @return array An array of data to bind to a mail template. + * + * @since __DEPLOY_VERSION__ + */ + private function getDataFromTask(Task $task): array + { + $lockOrExecTime = Factory::getDate($task->get('locked') ?? $task->get('last_execution'))->toRFC822(); + + return [ + 'TASK_ID' => $task->get('id'), + 'TASK_TITLE' => $task->get('title'), + 'EXIT_CODE' => $task->getContent()['status'] ?? Status::NO_EXIT, + 'EXEC_DATE_TIME' => $lockOrExecTime, + 'TASK_OUTPUT' => $task->getContent()['output_body'] ?? '', + ]; + } + + /** + * @param string $template The mail template. + * @param array $data The data to bind to the mail template. + * @param string $attachment The attachment to send with the mail (@todo multiple) + * + * @return void + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + private function sendMail(string $template, array $data, string $attachment = ''): void + { + $app = $this->app; + $db = $this->db; + + /** @var UserFactoryInterface $userFactory */ + $userFactory = Factory::getContainer()->get('user.factory'); + + // Get all users who are not blocked and have opted in for system mails. + $query = $db->getQuery(true); + + $query->select($db->qn(['name', 'email', 'sendEmail', 'id'])) + ->from($db->quoteName('#__users')) + ->where($db->quoteName('sendEmail') . ' = 1') + ->where($db->quoteName('block') . ' = 0'); + + $db->setQuery($query); + + try + { + $users = $db->loadObjectList(); + } + catch (RuntimeException $e) + { + return; + } + + if ($users === null) + { + Log::add(Text::_('PLG_SYSTEM_TASK_NOTIFICATION_USER_FETCH_FAIL'), Log::ERROR); + + return; + } + + $mailSent = false; + + // Mail all matching users who also have the `core.manage` privilege for com_scheduler. + foreach ($users as $user) + { + $user = $userFactory->loadUserById($user->id); + + if ($user->authorise('core.manage', 'com_scheduler')) + { + try + { + $mailer = new MailTemplate($template, $app->getLanguage()->getTag()); + $mailer->addTemplateData($data); + $mailer->addRecipient($user->email); + + if (!empty($attachment) + && File::exists($attachment) + && is_file($attachment)) + { + // @todo we allow multiple files [?] + $attachName = pathinfo($attachment, PATHINFO_BASENAME); + $mailer->addAttachment($attachName, $attachment); + } + + $mailer->send(); + $mailSent = true; + } + catch (MailerException $exception) + { + Log::add(Text::_('PLG_SYSTEM_TASK_NOTIFICATION_NOTIFY_SEND_EMAIL_FAIL'), Log::ERROR); + } + } + } + + if (!$mailSent) + { + Log::add(Text::_('PLG_SYSTEM_TASK_NOTIFICATION_NO_MAIL_SENT'), Log::WARNING); + } + } +} diff --git a/plugins/system/tasknotification/tasknotification.xml b/plugins/system/tasknotification/tasknotification.xml new file mode 100644 index 0000000000000..f238225a69a59 --- /dev/null +++ b/plugins/system/tasknotification/tasknotification.xml @@ -0,0 +1,20 @@ + + + plg_system_task_notification + Joomla! Project + September 2021 + (C) 2021 Open Source Matters, Inc. + GNU General Public License version 2 or later; see LICENSE.txt + admin@joomla.org + www.joomla.org + 4.1 + PLG_SYSTEM_TASK_NOTIFICATION_XML_DESCRIPTION + + tasknotification.php + language + + + language/en-GB/plg_system_tasknotification.ini + language/en-GB/plg_system_tasknotification.sys.ini + + diff --git a/plugins/task/checkfiles/checkfiles.php b/plugins/task/checkfiles/checkfiles.php new file mode 100644 index 0000000000000..c7df59a6c7e84 --- /dev/null +++ b/plugins/task/checkfiles/checkfiles.php @@ -0,0 +1,139 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Restrict direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Filesystem\Folder; +use Joomla\CMS\Image\Image; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; +use Joomla\Component\Scheduler\Administrator\Task\Status as TaskStatus; +use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; +use Joomla\Event\SubscriberInterface; + +/** + * Task plugin with routines that offer checks on files.
+ * At the moment, offers a single routine to check and resize image files in a directory. + * + * @since __DEPLOY_VERSION__ + */ +class PlgTaskCheckfiles extends CMSPlugin implements SubscriberInterface +{ + use TaskPluginTrait; + + /** + * @var string[] + * + * @since __DEPLOY_VERSION__ + */ + protected const TASKS_MAP = [ + 'checkfiles.imagesize' => [ + 'langConstPrefix' => 'PLG_TASK_CHECK_FILES_TASK_IMAGE_SIZE', + 'form' => 'image_size', + 'method' => 'checkImages', + ], + ]; + + /** + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected $autoloadLanguage = true; + + /** + * @inheritDoc + * + * @return string[] + * + * @since __DEPLOY_VERSION__ + */ + public static function getSubscribedEvents(): array + { + return [ + 'onTaskOptionsList' => 'advertiseRoutines', + 'onExecuteTask' => 'standardRoutineHandler', + 'onContentPrepareForm' => 'enhanceTaskItemForm', + ]; + } + + /** + * @param ExecuteTaskEvent $event The onExecuteTask event + * + * @return integer The exit code + * + * @since __DEPLOY_VERSION__ + * @throws RuntimeException + * @throws LogicException + */ + protected function checkImages(ExecuteTaskEvent $event): int + { + $params = $event->getArgument('params'); + + $path = JPATH_ROOT . '/images/' . $params->path; + $dimension = $params->dimension; + $limit = $params->limit; + + if (!Folder::exists($path)) + { + $this->logTask(Text::_('PLG_TASK_CHECK_FILES_LOG_IMAGE_PATH_NA'), 'warning'); + + return TaskStatus::NO_RUN; + } + + $images = Folder::files($path, '^.*\.(jpg|jpeg|png|gif|webp)', 2, true); + + foreach ($images as $imageFilename) + { + $properties = Image::getImageFileProperties($imageFilename); + $resize = $properties->$dimension > $limit; + + if (!$resize) + { + continue; + } + + $height = $properties->height; + $width = $properties->width; + + $newHeight = $dimension === 'height' ? $limit : $height * $limit / $width; + $newWidth = $dimension === 'width' ? $limit : $width * $limit / $height; + + $this->logTask(Text::sprintf('PLG_TASK_CHECK_FILES_LOG_RESIZING_IMAGE', $width, $height, $newWidth, $newHeight, $imageFilename)); + + $image = new Image($imageFilename); + + try + { + $image->resize($newWidth, $newHeight); + } + catch (LogicException $e) + { + $this->logTask('PLG_TASK_CHECK_FILES_LOG_RESIZE_FAIL', 'error'); + $resizeFail = true; + } + + if (!empty($resizeFail)) + { + return TaskStatus::KNOCKOUT; + } + + if (!$image->toFile($imageFilename, $properties->type)) + { + $this->logTask('PLG_TASK_CHECK_FILES_LOG_IMAGE_SAVE_FAIL', 'error'); + } + + // We do at most a single resize per execution + break; + } + + return TaskStatus::OK; + } +} diff --git a/plugins/task/checkfiles/checkfiles.xml b/plugins/task/checkfiles/checkfiles.xml new file mode 100644 index 0000000000000..1c0b366f646e4 --- /dev/null +++ b/plugins/task/checkfiles/checkfiles.xml @@ -0,0 +1,21 @@ + + + plg_task_check_files + Joomla! Project + August 2021 + (C) 2021 Open Source Matters, Inc. + GNU General Public License version 2 or later; see LICENSE.txt + admin@joomla.org + www.joomla.org + 4.1 + PLG_TASK_CHECK_FILES_XML_DESCRIPTION + + checkfiles.php + language + forms + + + language/en-GB/plg_task_checkfiles.ini + language/en-GB/plg_task_checkfiles.sys.ini + + diff --git a/plugins/task/checkfiles/forms/image_size.xml b/plugins/task/checkfiles/forms/image_size.xml new file mode 100644 index 0000000000000..16e403b30b337 --- /dev/null +++ b/plugins/task/checkfiles/forms/image_size.xml @@ -0,0 +1,34 @@ + +
+ +
+ + + + + + +
+
+
diff --git a/plugins/task/checkfiles/language/en-GB/plg_task_checkfiles.ini b/plugins/task/checkfiles/language/en-GB/plg_task_checkfiles.ini new file mode 100644 index 0000000000000..118ab90d9cf58 --- /dev/null +++ b/plugins/task/checkfiles/language/en-GB/plg_task_checkfiles.ini @@ -0,0 +1,16 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_TASK_CHECK_FILES="Task - Check Files" +PLG_TASK_CHECK_FILES_LABEL_DIMENSION_LIMIT="Limit" +PLG_TASK_CHECK_FILES_LABEL_DIRECTORY="Directory" +PLG_TASK_CHECK_FILES_LABEL_IMAGE_DIMENSION="Dimension" +PLG_TASK_CHECK_FILES_LOG_IMAGE_PATH_NA="Image path does exist!" +PLG_TASK_CHECK_FILES_LOG_IMAGE_SAVE_FAIL="Failed to save image file" +PLG_TASK_CHECK_FILES_LOG_RESIZE_FAIL="Failed to resize image due to an error in plugin logic..." +PLG_TASK_CHECK_FILES_LOG_RESIZING_IMAGE="Found image of size %1$sx%2$s px; resizing to %3$sx%4$s px. File: %5$s" +PLG_TASK_CHECK_FILES_TASK_IMAGE_SIZE_DESC="Check images, resize if larger than allowed." +PLG_TASK_CHECK_FILES_TASK_IMAGE_SIZE_TITLE="Image Size Check!" +PLG_TASK_CHECK_FILES_XML_DESCRIPTION="Offers task routines for checking for oversized files, and related actions if possible." diff --git a/plugins/task/checkfiles/language/en-GB/plg_task_checkfiles.sys.ini b/plugins/task/checkfiles/language/en-GB/plg_task_checkfiles.sys.ini new file mode 100644 index 0000000000000..11efd630767fd --- /dev/null +++ b/plugins/task/checkfiles/language/en-GB/plg_task_checkfiles.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_TASK_CHECK_FILES="Task - Check Files" +PLG_TASK_CHECK_FILES_XML_DESCRIPTION="Offers task routines for checking for oversized files, and related actions if possible." diff --git a/plugins/task/demotasks/demotasks.php b/plugins/task/demotasks/demotasks.php new file mode 100644 index 0000000000000..97c385bf465a2 --- /dev/null +++ b/plugins/task/demotasks/demotasks.php @@ -0,0 +1,172 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Restrict direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; +use Joomla\Component\Scheduler\Administrator\Task\Status; +use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; +use Joomla\Event\SubscriberInterface; + +/** + * A demo task plugin. Offers 3 task routines and demonstrates the use of {@see TaskPluginTrait}, + * {@see ExecuteTaskEvent}. + * + * @since __DEPLOY__VERSION__ + */ +class PlgTaskDemotasks extends CMSPlugin implements SubscriberInterface +{ + use TaskPluginTrait; + + /** + * @var string[] + * @since __DEPLOY_VERSION__ + */ + private const TASKS_MAP = [ + 'demoTask_r1.sleep' => [ + 'langConstPrefix' => 'PLG_TASK_DEMO_TASKS_TASK_SLEEP', + 'method' => 'sleep', + 'form' => 'testTaskForm', + ], + 'demoTask_r2.memoryStressTest' => [ + 'langConstPrefix' => 'PLG_TASK_DEMO_TASKS_STRESS_MEMORY', + 'method' => 'stressMemory', + ], + 'demoTask_r3.memoryStressTestOverride' => [ + 'langConstPrefix' => 'PLG_TASK_DEMO_TASKS_STRESS_MEMORY_OVERRIDE', + 'method' => 'stressMemoryRemoveLimit', + ], + ]; + + /** + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected $autoloadLanguage = true; + + /** + * @inheritDoc + * + * @return string[] + * + * @since __DEPLOY_VERSION__ + */ + public static function getSubscribedEvents(): array + { + return [ + 'onTaskOptionsList' => 'advertiseRoutines', + 'onExecuteTask' => 'standardRoutineHandler', + 'onContentPrepareForm' => 'enhanceTaskItemForm', + ]; + } + + /** + * @param ExecuteTaskEvent $event The `onExecuteTask` event. + * + * @return integer The routine exit code. + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + private function sleep(ExecuteTaskEvent $event): int + { + $timeout = (int) $event->getArgument('params')->timeout ?? 1; + + $this->logTask(sprintf('Starting %d timeout', $timeout)); + sleep($timeout); + $this->logTask(sprintf('%d timeout over!', $timeout)); + + return Status::OK; + } + + /** + * Standard routine method for the memory test routine. + * + * @param ExecuteTaskEvent $event The `onExecuteTask` event. + * + * @return integer The routine exit code. + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + private function stressMemory(ExecuteTaskEvent $event): int + { + $mLimit = $this->getMemoryLimit(); + $this->logTask(sprintf('Memory Limit: %d KB', $mLimit)); + + $iMem = $cMem = memory_get_usage(); + $i = 0; + + while ($cMem + ($cMem - $iMem) / ++$i <= $mLimit) + { + $this->logTask(sprintf('Current memory usage: %d KB', $cMem)); + ${"array" . $i} = array_fill(0, 100000, 1); + } + + return Status::OK; + } + + /** + * Standard routine method for the memory test routine, also attempts to override the memory limit set by the PHP + * INI. + * + * @param ExecuteTaskEvent $event The `onExecuteTask` event. + * + * @return integer The routine exit code. + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + private function stressMemoryRemoveLimit(ExecuteTaskEvent $event): int + { + $success = false; + + if (function_exists('ini_set')) + { + $success = ini_set('memory_limit', -1) !== false; + } + + $this->logTask('Memory limit override ' . $success ? 'successful' : 'failed'); + + return $this->stressMemory($event); + } + + /** + * Processes the PHP ini memory_limit setting, returning the memory limit in KB + * + * @return float + * + * @since __DEPLOY_VERSION__ + */ + private function getMemoryLimit(): float + { + $memoryLimit = ini_get('memory_limit'); + + if (preg_match('/^(\d+)(.)$/', $memoryLimit, $matches)) + { + if ($matches[2] == 'M') + { + // * nnnM -> nnn MB + $memoryLimit = $matches[1] * 1024 * 1024; + } + else + { + if ($matches[2] == 'K') + { + // * nnnK -> nnn KB + $memoryLimit = $matches[1] * 1024; + } + } + } + + return (float) $memoryLimit; + } +} diff --git a/plugins/task/demotasks/demotasks.xml b/plugins/task/demotasks/demotasks.xml new file mode 100644 index 0000000000000..25ec1ec22efb2 --- /dev/null +++ b/plugins/task/demotasks/demotasks.xml @@ -0,0 +1,21 @@ + + + plg_task_demo_tasks + Joomla! Project + July 2021 + (C) 2021 Open Source Matters, Inc. + GNU General Public License version 2 or later; see LICENSE.txt + admin@joomla.org + www.joomla.org + 4.1 + Demo task routines for com_scheduler + + demotasks.php + language + forms + + + language/en-GB/plg_task_demotasks.ini + language/en-GB/plg_task_demotasks.sys.ini + + diff --git a/plugins/task/demotasks/forms/testTaskForm.xml b/plugins/task/demotasks/forms/testTaskForm.xml new file mode 100644 index 0000000000000..bba438e7c690e --- /dev/null +++ b/plugins/task/demotasks/forms/testTaskForm.xml @@ -0,0 +1,18 @@ + +
+ +
+ +
+
+
diff --git a/plugins/task/demotasks/language/en-GB/plg_task_demotasks.ini b/plugins/task/demotasks/language/en-GB/plg_task_demotasks.ini new file mode 100644 index 0000000000000..056a9de80e62e --- /dev/null +++ b/plugins/task/demotasks/language/en-GB/plg_task_demotasks.ini @@ -0,0 +1,15 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_TASK_DEMO_TASKS="Task - Demo Tasks!" +PLG_TASK_DEMO_TASKS_SLEEP_TIMEOUT_LABEL="Sleep Timeout (seconds)" +PLG_TASK_DEMO_TASKS_STRESS_MEMORY_DESC="What happens to a task when the PHP memory limit is exhausted?" +PLG_TASK_DEMO_TASKS_STRESS_MEMORY_OVERRIDE_DESC="What happens to a task when the system memory is exhausted?" +PLG_TASK_DEMO_TASKS_STRESS_MEMORY_OVERRIDE_TITLE="Stress Memory, Override Limit" +PLG_TASK_DEMO_TASKS_STRESS_MEMORY_TITLE="Stress Memory" +PLG_TASK_DEMO_TASKS_TASK_SLEEP_DESC="Sleep, do nothing for x seconds." +PLG_TASK_DEMO_TASKS_TASK_SLEEP_ROUTINE_END_LOG_MESSAGE="TestTask1 return code is: %1$d. Processing Time: %2$.2f seconds" +PLG_TASK_DEMO_TASKS_TASK_SLEEP_TITLE="Demo Task - Sleep" +PLG_TASK_DEMO_TASKS_XML_DESCRIPTION="This is a demo plugin for the development of Joomla! Scheduled Tasks." diff --git a/plugins/task/demotasks/language/en-GB/plg_task_demotasks.sys.ini b/plugins/task/demotasks/language/en-GB/plg_task_demotasks.sys.ini new file mode 100644 index 0000000000000..eae925bdd1289 --- /dev/null +++ b/plugins/task/demotasks/language/en-GB/plg_task_demotasks.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_TASK_DEMO_TASKS="Task - Demo Tasks!" +PLG_TASK_DEMO_TASKS_XML_DESCRIPTION="This is a demo plugin for the development of Joomla! Scheduled Tasks." diff --git a/plugins/task/requests/forms/get_requests.xml b/plugins/task/requests/forms/get_requests.xml new file mode 100644 index 0000000000000..00bdc0440bf73 --- /dev/null +++ b/plugins/task/requests/forms/get_requests.xml @@ -0,0 +1,53 @@ + +
+ +
+ + + + + + + + + + + +
+
+
diff --git a/plugins/task/requests/language/en-GB/plg_task_requests.ini b/plugins/task/requests/language/en-GB/plg_task_requests.ini new file mode 100644 index 0000000000000..293644e86a90f --- /dev/null +++ b/plugins/task/requests/language/en-GB/plg_task_requests.ini @@ -0,0 +1,20 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_TASK_REQUESTS="Task - Requests" +PLG_TASK_REQUESTS_BEARER="Bearer" +PLG_TASK_REQUESTS_JOOMLA_TOKEN="X-Joomla-Token" +PLG_TASK_REQUESTS_LABEL_AUTH="Authorization" +PLG_TASK_REQUESTS_LABEL_AUTH_HEADER="Authorization Header" +PLG_TASK_REQUESTS_LABEL_AUTH_KEY="Authorization Key" +PLG_TASK_REQUESTS_LABEL_REQUEST_TIMEOUT="Request Timeout" +PLG_TASK_REQUESTS_LABEL_REQUEST_URL="Request URL" +PLG_TASK_REQUESTS_TASK_GET_REQUEST_DESC="Make GET requests to a server. Supports a custom timeout and authorization headers." +PLG_TASK_REQUESTS_TASK_GET_REQUEST_LOG_RESPONSE="Request response code was: %1$d" +PLG_TASK_REQUESTS_TASK_GET_REQUEST_LOG_TIMEOUT="GET request failed or timed out." +PLG_TASK_REQUESTS_TASK_GET_REQUEST_LOG_UNWRITEABLE_OUTPUT="Unable write output file!" +PLG_TASK_REQUESTS_TASK_GET_REQUEST_ROUTINE_END_LOG_MESSAGE="GET return code is: %1$d. Processing Time: %2$.2f seconds" +PLG_TASK_REQUESTS_TASK_GET_REQUEST_TITLE="GET Request" +PLG_TASK_REQUESTS_XML_DESCRIPTION="Job plugin to make GET requests to a server." diff --git a/plugins/task/requests/language/en-GB/plg_task_requests.sys.ini b/plugins/task/requests/language/en-GB/plg_task_requests.sys.ini new file mode 100644 index 0000000000000..148a036d31676 --- /dev/null +++ b/plugins/task/requests/language/en-GB/plg_task_requests.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_TASK_REQUESTS="Task - Requests" +PLG_TASK_REQUESTS_XML_DESCRIPTION="Job plugin to make GET requests to a server." diff --git a/plugins/task/requests/requests.php b/plugins/task/requests/requests.php new file mode 100644 index 0000000000000..1e8b0337729ae --- /dev/null +++ b/plugins/task/requests/requests.php @@ -0,0 +1,141 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Restrict direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Filesystem\File; +use Joomla\CMS\Filesystem\Path; +use Joomla\CMS\Http\HttpFactory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; +use Joomla\Component\Scheduler\Administrator\Task\Status as TaskStatus; +use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; +use Joomla\Event\SubscriberInterface; +use Joomla\Registry\Registry; + +/** + * Task plugin with routines to make HTTP requests.
+ * At the moment, offers a single routine for GET requests. + * + * @since __DEPLOY_VERSION__ + */ +class PlgTaskRequests extends CMSPlugin implements SubscriberInterface +{ + use TaskPluginTrait; + + /** + * @var string[] + * @since __DEPLOY_VERSION__ + */ + protected const TASKS_MAP = [ + 'plg_task_requests_task_get' => [ + 'langConstPrefix' => 'PLG_TASK_REQUESTS_TASK_GET_REQUEST', + 'form' => 'get_requests', + 'method' => 'makeGetRequest', + ], + ]; + + /** + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected $autoloadLanguage = true; + + /** + * @inheritDoc + * + * @return string[] + * + * @since __DEPLOY_VERSION__ + */ + public static function getSubscribedEvents(): array + { + return [ + 'onTaskOptionsList' => 'advertiseRoutines', + 'onExecuteTask' => 'standardRoutineHandler', + 'onContentPrepareForm' => 'enhanceTaskItemForm', + ]; + } + + /** + * Standard routine method for the get request routine. + * + * @param ExecuteTaskEvent $event The onExecuteTask event + * + * @return integer The exit code + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + protected function makeGetRequest(ExecuteTaskEvent $event): int + { + $id = $event->getTaskId(); + $params = $event->getArgument('params'); + + $url = $params->url; + $timeout = $params->timeout; + $auth = (string) $params->auth ?? 0; + $authType = (string) $params->authType ?? ''; + $authKey = (string) $params->authKey ?? ''; + $headers = []; + + if ($auth && $authType && $authKey) + { + $headers = [$authType => $authKey]; + } + + $options = new Registry; + + try + { + $response = HttpFactory::getHttp($options)->get($url, $headers, $timeout); + } + catch (Exception $e) + { + $this->logTask(Text::sprintf('PLG_TASK_REQUESTS_TASK_GET_REQUEST_LOG_TIMEOUT')); + + return TaskStatus::TIMEOUT; + } + + $responseCode = $response->code; + $responseBody = $response->body; + + // @todo this handling must be rethought and made safe. stands as a good demo right now. + $responseFilename = Path::clean(JPATH_ROOT . "/tmp/task_{$id}_response.html"); + + if (File::write($responseFilename, $responseBody)) + { + $this->snapshot['output_file'] = $responseFilename; + $responseStatus = 'SAVED'; + } + else + { + $this->logTask('PLG_TASK_REQUESTS_TASK_GET_REQUEST_LOG_UNWRITEABLE_OUTPUT', 'error'); + $responseStatus = 'NOT_SAVED'; + } + + $this->snapshot['output'] = <<< EOF +======= Task Output Body ======= +> URL: $url +> Response Code: $responseCode +> Response: $responseStatus +EOF; + + $this->logTask(Text::sprintf('PLG_TASK_REQUESTS_TASK_GET_REQUEST_LOG_RESPONSE', $responseCode)); + + if ($response->code !== 200) + { + return TaskStatus::KNOCKOUT; + } + + return TaskStatus::OK; + } +} diff --git a/plugins/task/requests/requests.xml b/plugins/task/requests/requests.xml new file mode 100644 index 0000000000000..1bb8a65e92d33 --- /dev/null +++ b/plugins/task/requests/requests.xml @@ -0,0 +1,21 @@ + + + plg_task_requests + Joomla! Project + August 2021 + (C) 2021 Open Source Matters, Inc. + GNU General Public License version 2 or later; see LICENSE.txt + admin@joomla.org + www.joomla.org + 4.1 + PLG_TASK_REQUESTS_XML_DESCRIPTION + + requests.php + language + forms + + + language/en-GB/plg_task_requests.ini + language/en-GB/plg_task_requests.sys.ini + + diff --git a/plugins/task/sitestatus/language/en-GB/plg_task_sitestatus.ini b/plugins/task/sitestatus/language/en-GB/plg_task_sitestatus.ini new file mode 100644 index 0000000000000..508ea4e010ba4 --- /dev/null +++ b/plugins/task/sitestatus/language/en-GB/plg_task_sitestatus.ini @@ -0,0 +1,20 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_TASK_SITE_STATUS="Task - Site Status" +PLG_TASK_SITE_STATUS_DESC="Toggles the site's status on each run." +PLG_TASK_SITE_STATUS_ERROR_CONFIGURATION_PHP_NOTUNWRITABLE="Could not make configuration.php un-writable." +PLG_TASK_SITE_STATUS_ERROR_CONFIGURATION_PHP_NOTWRITABLE="Could not make configuration.php writable." +PLG_TASK_SITE_STATUS_ERROR_WRITE_FAILED="Could not write to the configuration file!" +PLG_TASK_SITE_STATUS_ROUTINE_END_LOG_MESSAGE="ToggleOffline return code is: %1$d. Processing Time: %2$.2f seconds." +PLG_TASK_SITE_STATUS_TASK_LOG_SITE_STATUS="Site was %1$s, is now %2$s." +PLG_TASK_SITE_STATUS_SET_OFFLINE_DESC="Sets site offline to online on each run." +PLG_TASK_SITE_STATUS_SET_OFFLINE_ROUTINE_END_LOG_MESSAGE="SetOffline return code is: %1$d. Processing Time: %2$.2f seconds." +PLG_TASK_SITE_STATUS_SET_OFFLINE_TITLE="Set Site Offline." +PLG_TASK_SITE_STATUS_SET_ONLINE_DESC="Sets site status to online on each run." +PLG_TASK_SITE_STATUS_SET_ONLINE_ROUTINE_END_LOG_MESSAGE="SetOnline return code is: %1$d. Processing Time: %2$.2f seconds." +PLG_TASK_SITE_STATUS_SET_ONLINE_TITLE="Set Site Online." +PLG_TASK_SITE_STATUS_TITLE="Toggle Offline." +PLG_TASK_SITE_STATUS_XML_DESCRIPTION="Offers task routines to change the site's offline status." diff --git a/plugins/task/sitestatus/language/en-GB/plg_task_sitestatus.sys.ini b/plugins/task/sitestatus/language/en-GB/plg_task_sitestatus.sys.ini new file mode 100644 index 0000000000000..eb0735664baad --- /dev/null +++ b/plugins/task/sitestatus/language/en-GB/plg_task_sitestatus.sys.ini @@ -0,0 +1,7 @@ +; Joomla! Project +; (C) 2021 Open Source Matters, Inc. +; GNU General Public License version 2 or later; see LICENSE.txt +; Note : All ini files need to be saved as UTF-8 + +PLG_TASK_SITE_STATUS="Task - Site Status" +PLG_TASK_SITE_STATUS_XML_DESCRIPTION="Offers task routines to change the site's offline status." diff --git a/plugins/task/sitestatus/sitestatus.php b/plugins/task/sitestatus/sitestatus.php new file mode 100644 index 0000000000000..7147f69d80c3e --- /dev/null +++ b/plugins/task/sitestatus/sitestatus.php @@ -0,0 +1,172 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +// Restrict direct access +defined('_JEXEC') or die; + +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Filesystem\File; +use Joomla\CMS\Filesystem\Path; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; +use Joomla\Component\Scheduler\Administrator\Task\Status; +use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; +use Joomla\Event\SubscriberInterface; +use Joomla\Registry\Registry; +use Joomla\Utilities\ArrayHelper; + +/** + * Task plugin with routines to change the offline status of the site. These routines can be used to control planned + * maintenance periods and related operations. + * + * @since __DEPLOY_VERSION__ + */ +class PlgTaskSitestatus extends CMSPlugin implements SubscriberInterface +{ + use TaskPluginTrait; + + /** + * @var string[] + * @since __DEPLOY_VERSION__ + */ + protected const TASKS_MAP = [ + 'plg_task_toggle_offline' => [ + 'langConstPrefix' => 'PLG_TASK_SITE_STATUS', + 'toggle' => true, + ], + 'plg_task_toggle_offline_set_online' => [ + 'langConstPrefix' => 'PLG_TASK_SITE_STATUS_SET_ONLINE', + 'toggle' => false, + 'offline' => false, + ], + 'plg_task_toggle_offline_set_offline' => [ + 'langConstPrefix' => 'PLG_TASK_SITE_STATUS_SET_OFFLINE', + 'toggle' => false, + 'offline' => true, + ], + + ]; + + /** + * The application object. + * + * @var CMSApplication + * @since __DEPLOY_VERSION__ + */ + protected $app; + + /** + * Autoload the language file. + * + * @var boolean + * @since __DEPLOY_VERSION__ + */ + protected $autoloadLanguage = true; + + /** + * @inheritDoc + * + * @return string[] + * + * @since __DEPLOY_VERSION__ + */ + public static function getSubscribedEvents(): array + { + return [ + 'onTaskOptionsList' => 'advertiseRoutines', + 'onExecuteTask' => 'alterSiteStatus', + ]; + } + + /** + * @param ExecuteTaskEvent $event The onExecuteTask event + * + * @return void + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + public function alterSiteStatus(ExecuteTaskEvent $event): void + { + if (!array_key_exists($event->getRoutineId(), self::TASKS_MAP)) + { + return; + } + + $this->startRoutine($event); + + $config = ArrayHelper::fromObject(new JConfig); + + $toggle = self::TASKS_MAP[$event->getRoutineId()]['toggle']; + $oldStatus = $config['offline'] ? 'offline' : 'online'; + + if ($toggle) + { + $config['offline'] = !$config['offline']; + } + else + { + $offline = self::TASKS_MAP[$event->getRoutineId()]['offline']; + $config['offline'] = $offline; + } + + $newStatus = $config['offline'] ? 'offline' : 'online'; + $exit = $this->writeConfigFile(new Registry($config)); + $this->logTask(Text::sprintf('PLG_TASK_SITE_STATUS_TASK_LOG_SITE_STATUS', $oldStatus, $newStatus)); + + $this->endRoutine($event, $exit); + } + + /** + * Method to write the configuration to a file. + * + * @param Registry $config A Registry object containing all global config data. + * + * @return integer The task exit code + * + * @since __DEPLOY_VERSION__ + * @throws Exception + */ + private function writeConfigFile(Registry $config): int + { + // Set the configuration file path. + $file = JPATH_CONFIGURATION . '/configuration.php'; + + // Attempt to make the file writeable. + if (Path::isOwner($file) && !Path::setPermissions($file)) + { + $this->logTask(Text::_('PLG_TASK_SITE_STATUS_ERROR_CONFIGURATION_PHP_NOTWRITABLE'), 'notice'); + } + + // Attempt to write the configuration file as a PHP class named JConfig. + $configuration = $config->toString('PHP', array('class' => 'JConfig', 'closingtag' => false)); + + if (!File::write($file, $configuration)) + { + $this->logTask(Text::_('PLG_TASK_SITE_STATUS_ERROR_WRITE_FAILED'), 'error'); + + return Status::KNOCKOUT; + } + + // Invalidates the cached configuration file + if (function_exists('opcache_invalidate')) + { + opcache_invalidate($file); + } + + // Attempt to make the file un-writeable. + if (Path::isOwner($file) && !Path::setPermissions($file, '0444')) + { + $this->logTask(Text::_('PLG_TASK_SITE_STATUS_ERROR_CONFIGURATION_PHP_NOTUNWRITABLE'), 'notice'); + } + + return Status::OK; + } +} diff --git a/plugins/task/sitestatus/sitestatus.xml b/plugins/task/sitestatus/sitestatus.xml new file mode 100644 index 0000000000000..ce7ff4ec33d74 --- /dev/null +++ b/plugins/task/sitestatus/sitestatus.xml @@ -0,0 +1,21 @@ + + + plg_task_site_status + Joomla! Project + August 2021 + (C) 2021 Open Source Matters, Inc. + GNU General Public License version 2 or later; see LICENSE.txt + admin@joomla.org + www.joomla.org + 4.1 + PLG_TASK_SITE_STATUS_XML_DESCRIPTION + + sitestatus.php + language + forms + + + language/en-GB/plg_task_sitestatus.ini + language/en-GB/plg_task_sitestatus.sys.ini + +