first commit
This commit is contained in:
25
plugins/system/schedulerunner/schedulerunner.xml
Normal file
25
plugins/system/schedulerunner/schedulerunner.xml
Normal file
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>plg_system_schedulerunner</name>
|
||||
<author>Joomla! Project</author>
|
||||
<creationDate>2021-08</creationDate>
|
||||
<copyright>(C) 2021 Open Source Matters, Inc.</copyright>
|
||||
<license>GNU General Public License version 2 or later; see LICENSE.txt</license>
|
||||
<authorEmail>admin@joomla.org</authorEmail>
|
||||
<authorUrl>www.joomla.org</authorUrl>
|
||||
<version>4.1</version>
|
||||
<description>PLG_SYSTEM_SCHEDULERUNNER_XML_DESCRIPTION</description>
|
||||
<namespace path="src">Joomla\Plugin\System\ScheduleRunner</namespace>
|
||||
<media destination="plg_system_schedulerunner" folder="media">
|
||||
<folder>js</folder>
|
||||
<filename>joomla.asset.json</filename>
|
||||
</media>
|
||||
<files>
|
||||
<folder plugin="schedulerunner">services</folder>
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_system_schedulerunner.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_system_schedulerunner.sys.ini</language>
|
||||
</languages>
|
||||
</extension>
|
||||
46
plugins/system/schedulerunner/services/provider.php
Normal file
46
plugins/system/schedulerunner/services/provider.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.schedulerunner
|
||||
*
|
||||
* @copyright (C) 2023 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
\defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Joomla\Plugin\System\ScheduleRunner\Extension\ScheduleRunner;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
/**
|
||||
* Registers the service provider with a DI container.
|
||||
*
|
||||
* @param Container $container The DI container.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.4.0
|
||||
*/
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$plugin = new ScheduleRunner(
|
||||
$container->get(DispatcherInterface::class),
|
||||
(array) PluginHelper::getPlugin('system', 'schedulerunner')
|
||||
);
|
||||
$plugin->setApplication(Factory::getApplication());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
357
plugins/system/schedulerunner/src/Extension/ScheduleRunner.php
Normal file
357
plugins/system/schedulerunner/src/Extension/ScheduleRunner.php
Normal file
@ -0,0 +1,357 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.schedulerunner
|
||||
*
|
||||
* @copyright (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\ScheduleRunner\Extension;
|
||||
|
||||
use Joomla\CMS\Component\ComponentHelper;
|
||||
use Joomla\CMS\Event\Model;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Table\Extension;
|
||||
use Joomla\CMS\User\UserHelper;
|
||||
use Joomla\Component\Scheduler\Administrator\Model\TasksModel;
|
||||
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;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* 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 4.1.0
|
||||
*/
|
||||
final class ScheduleRunner extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
/**
|
||||
* Length of auto-generated webcron key.
|
||||
*
|
||||
* @var integer
|
||||
* @since 4.1.0
|
||||
*/
|
||||
private const WEBCRON_KEY_LENGTH = 20;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*
|
||||
* @return string[]
|
||||
*
|
||||
* @since 4.1.0
|
||||
*
|
||||
* @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 4.1.0
|
||||
*/
|
||||
public function injectLazyJS(EventInterface $event): void
|
||||
{
|
||||
// Only inject in HTML documents
|
||||
if ($this->getApplication()->getDocument()->getType() !== 'html') {
|
||||
return;
|
||||
}
|
||||
|
||||
$config = ComponentHelper::getParams('com_scheduler');
|
||||
|
||||
if (!$config->get('lazy_scheduler.enabled', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var TasksModel $model */
|
||||
$model = $this->getApplication()->bootComponent('com_scheduler')
|
||||
->getMVCFactory()->createModel('Tasks', 'Administrator', ['ignore_request' => true]);
|
||||
|
||||
$now = Factory::getDate('now', 'UTC');
|
||||
|
||||
if (!$model->hasDueTasks($now)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add configuration options
|
||||
$triggerInterval = $config->get('lazy_scheduler.interval', 300);
|
||||
$this->getApplication()->getDocument()->addScriptOptions('plg_system_schedulerunner', ['interval' => $triggerInterval]);
|
||||
|
||||
// Load and injection directive
|
||||
$wa = $this->getApplication()->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 4.1.0
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function runLazyCron(EventInterface $e)
|
||||
{
|
||||
$config = ComponentHelper::getParams('com_scheduler');
|
||||
|
||||
if (!$config->get('lazy_scheduler.enabled', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Since the the request from the frontend may time out, try allowing execution after disconnect.
|
||||
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.<br/>
|
||||
* 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.<br/>
|
||||
*
|
||||
* 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 4.1.0
|
||||
*
|
||||
* @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($this->getApplication()->getLanguage()->_('PLG_SYSTEM_SCHEDULE_RUNNER_WEBCRON_DISABLED'));
|
||||
throw new \Exception($this->getApplication()->getLanguage()->_('JERROR_ALERTNOAUTHOR'), 403);
|
||||
}
|
||||
|
||||
if (!\strlen($hash) || $hash !== $this->getApplication()->getInput()->get('hash')) {
|
||||
throw new \Exception($this->getApplication()->getLanguage()->_('JERROR_ALERTNOAUTHOR'), 403);
|
||||
}
|
||||
|
||||
$id = (int) $this->getApplication()->getInput()->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 4.1.0
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function runTestCron(Event $event)
|
||||
{
|
||||
if (!Session::checkToken('GET')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$id = (int) $this->getApplication()->getInput()->getInt('id');
|
||||
$allowConcurrent = $this->getApplication()->getInput()->getBool('allowConcurrent', false);
|
||||
|
||||
$user = $this->getApplication()->getIdentity();
|
||||
|
||||
if (empty($id) || !$user->authorise('core.testrun', 'com_scheduler.task.' . $id)) {
|
||||
throw new \Exception($this->getApplication()->getLanguage()->_('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 ($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 4.1.0
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
private 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 Model\PrepareFormEvent $event The onContentPrepareForm event.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.1.0
|
||||
* @throws \UnexpectedValueException|\RuntimeException
|
||||
*
|
||||
* @todo Move to another plugin?
|
||||
*/
|
||||
public function enhanceSchedulerConfig(Model\PrepareFormEvent $event): void
|
||||
{
|
||||
$form = $event->getForm();
|
||||
$data = $event->getData();
|
||||
|
||||
if (
|
||||
$form->getName() !== 'com_config.component'
|
||||
|| $this->getApplication()->getInput()->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 4.1.0
|
||||
*/
|
||||
public function generateWebcronKey(EventInterface $event): void
|
||||
{
|
||||
/** @var Extension $table */
|
||||
[$context, $table] = array_values($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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user