381 lines
13 KiB
PHP
381 lines
13 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @file AcronPlugin.php
|
|
*
|
|
* Copyright (c) 2013-2022 Simon Fraser University
|
|
* Copyright (c) 2000-2022 John Willinsky
|
|
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
|
|
*
|
|
* @class AcronPlugin
|
|
*
|
|
* @brief Removes dependency on 'cron' for scheduled tasks, including
|
|
* possible tasks defined by plugins. See the AcronPlugin::parseCrontab
|
|
* hook implementation.
|
|
*/
|
|
|
|
namespace APP\plugins\generic\acron;
|
|
|
|
use APP\core\Application;
|
|
use APP\notification\NotificationManager;
|
|
use Closure;
|
|
use Illuminate\Support\Facades\Event;
|
|
use PKP\config\Config;
|
|
use PKP\core\JSONMessage;
|
|
use PKP\core\PKPPageRouter;
|
|
use PKP\db\DAORegistry;
|
|
use PKP\linkAction\LinkAction;
|
|
use PKP\linkAction\request\AjaxAction;
|
|
use PKP\notification\PKPNotification;
|
|
use PKP\observers\events\PluginSettingChanged;
|
|
use PKP\plugins\GenericPlugin;
|
|
use PKP\plugins\Hook;
|
|
use PKP\plugins\PluginRegistry;
|
|
use PKP\scheduledTask\ScheduledTask;
|
|
use PKP\scheduledTask\ScheduledTaskDAO;
|
|
use PKP\scheduledTask\ScheduledTaskHelper;
|
|
use PKP\xml\PKPXMLParser;
|
|
use PKP\xml\XMLNode;
|
|
use ReflectionFunction;
|
|
|
|
// TODO: Error handling. If a scheduled task encounters an error...?
|
|
|
|
class AcronPlugin extends GenericPlugin
|
|
{
|
|
private array $_tasksToRun;
|
|
|
|
/**
|
|
* @copydoc Plugin::register()
|
|
*
|
|
* @param null|mixed $mainContextId
|
|
*/
|
|
public function register($category, $path, $mainContextId = null): bool
|
|
{
|
|
$success = parent::register($category, $path, $mainContextId);
|
|
Hook::add('Installer::postInstall', fn (string $hookName, array $args) => $this->_callbackPostInstall($hookName, $args));
|
|
|
|
if (Application::isUnderMaintenance()) {
|
|
return $success;
|
|
}
|
|
if ($success) {
|
|
$this->addLocaleData();
|
|
Hook::add('LoadHandler', fn (string $hookName, array $args) => $this->_callbackLoadHandler($hookName, $args));
|
|
// Reload cron tab when a plugin is enabled/disabled
|
|
Event::listen(PluginSettingChanged::class, fn (PluginSettingChanged $event) => $this->_callbackManage($event));
|
|
}
|
|
return $success;
|
|
}
|
|
|
|
/**
|
|
* @copydoc Plugin::isSitePlugin()
|
|
*/
|
|
public function isSitePlugin(): bool
|
|
{
|
|
// This is a site-wide plugin.
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @copydoc LazyLoadPlugin::getName()
|
|
*/
|
|
public function getName(): string
|
|
{
|
|
return 'acronPlugin';
|
|
}
|
|
|
|
/**
|
|
* @copydoc Plugin::getDisplayName()
|
|
*/
|
|
public function getDisplayName(): string
|
|
{
|
|
return __('plugins.generic.acron.name');
|
|
}
|
|
|
|
/**
|
|
* @copydoc Plugin::getDescription()
|
|
*/
|
|
public function getDescription(): string
|
|
{
|
|
return __('plugins.generic.acron.description');
|
|
}
|
|
|
|
/**
|
|
* @copydoc Plugin::getInstallSitePluginSettingsFile()
|
|
*/
|
|
public function getInstallSitePluginSettingsFile(): string
|
|
{
|
|
return "{$this->getPluginPath()}/settings.xml";
|
|
}
|
|
|
|
/**
|
|
* @copydoc Plugin::getActions()
|
|
*/
|
|
public function getActions($request, $actionArgs): array
|
|
{
|
|
$router = $request->getRouter();
|
|
$actions = parent::getActions($request, $actionArgs);
|
|
if ($this->getEnabled()) {
|
|
$url = $router->url($request, null, null, 'manage', null, ['verb' => 'reload', 'plugin' => $this->getName(), 'category' => 'generic']);
|
|
array_unshift($actions, new LinkAction('reload', new AjaxAction($url), __('plugins.generic.acron.reload')));
|
|
}
|
|
return $actions;
|
|
}
|
|
|
|
/**
|
|
* @see Plugin::manage()
|
|
*/
|
|
public function manage($args, $request): JSONMessage
|
|
{
|
|
if ($request->getUserVar('verb') !== 'reload') {
|
|
return parent::manage($args, $request);
|
|
}
|
|
|
|
$this->_parseCrontab();
|
|
$notificationManager = new NotificationManager();
|
|
$user = $request->getUser();
|
|
$notificationManager->createTrivialNotification(
|
|
$user->getId(),
|
|
PKPNotification::NOTIFICATION_TYPE_SUCCESS,
|
|
['contents' => __('plugins.generic.acron.tasksReloaded')]
|
|
);
|
|
return \PKP\db\DAO::getDataChangedEvent();
|
|
}
|
|
|
|
/**
|
|
* Post install hook to flag cron tab reload on every install/upgrade.
|
|
*
|
|
* @see Installer::postInstall() for the hook call.
|
|
*/
|
|
private function _callbackPostInstall(string $hookName, array $args): bool
|
|
{
|
|
$this->_parseCrontab();
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Load handler hook to check for tasks to run.
|
|
*
|
|
* @see PKPPageRouter::loadHandler() for the hook call.
|
|
*/
|
|
private function _callbackLoadHandler(string $hookName, array $args): bool
|
|
{
|
|
$request = Application::get()->getRequest();
|
|
$router = $request->getRouter();
|
|
// Avoid controllers requests because of the shutdown function usage.
|
|
if (!($router instanceof PKPPageRouter)) {
|
|
return false;
|
|
}
|
|
|
|
// Application is set to sandbox mode and will not run any schedule tasks
|
|
if (Config::getVar('general', 'sandbox', false)) {
|
|
error_log('Application is set to sandbox mode and will not run any schedule tasks');
|
|
return false;
|
|
}
|
|
|
|
$tasksToRun = $this->_getTasksToRun();
|
|
if (empty($tasksToRun)) {
|
|
return false;
|
|
}
|
|
|
|
// Save the current working directory, so we can fix
|
|
// it inside the shutdown function.
|
|
$workingDir = getcwd();
|
|
|
|
// Save the tasks to be executed.
|
|
$this->_tasksToRun = $tasksToRun;
|
|
|
|
// Need output buffering to send a finish message
|
|
// to browser inside the shutdown function. Couldn't
|
|
// do without the buffer.
|
|
ob_start();
|
|
|
|
// This callback will be used as soon as the main script
|
|
// is finished. It will not stop running, even if the user cancels
|
|
// the request or the time limit is reach.
|
|
register_shutdown_function(fn () => $this->_shutdownFunction($workingDir));
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Synchronize crontab with lazy load plugins management.
|
|
*
|
|
* @see PluginHandler::plugin() for the hook call.
|
|
*/
|
|
private function _callbackManage(PluginSettingChanged $event): bool
|
|
{
|
|
if ($event->settingName !== 'enabled') {
|
|
return false;
|
|
}
|
|
|
|
// Check if the plugin wants to add its own scheduled task into the cron tab.
|
|
foreach (Hook::getHooks('AcronPlugin::parseCronTab') ?? [] as $hookPriorityList) {
|
|
foreach ($hookPriorityList as $callback) {
|
|
$reflection = new ReflectionFunction(Closure::fromCallable($callback));
|
|
if ($reflection->getClosureThis() === $event->plugin) {
|
|
$this->_parseCrontab();
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Shutdown callback.
|
|
*/
|
|
private function _shutdownFunction(string $workingDir): void
|
|
{
|
|
// Release requests from waiting the processing.
|
|
header('Connection: close');
|
|
// This header is needed so avoid using any kind of compression. If zlib is
|
|
// enabled, for example, the buffer will not output until the end of the
|
|
// script execution.
|
|
header('Content-Encoding: none');
|
|
header('Content-Length: ' . ob_get_length());
|
|
ob_end_flush();
|
|
flush();
|
|
|
|
set_time_limit(0);
|
|
|
|
// Fix the current working directory. See
|
|
// http://www.php.net/manual/en/function.register-shutdown-function.php#92657
|
|
chdir($workingDir);
|
|
|
|
/** @var ScheduledTaskDAO */
|
|
$taskDao = DAORegistry::getDAO('ScheduledTaskDAO');
|
|
foreach ($this->_tasksToRun as $task) {
|
|
$className = $task['className'];
|
|
$taskArgs = $task['args'] ?? [];
|
|
|
|
// There's a race here. Several requests may come in closely spaced.
|
|
// Each may decide it's time to run scheduled tasks, and more than one
|
|
// can happily go ahead and do it before the "last run" time is updated.
|
|
// By updating the last run time as soon as feasible, we can minimize
|
|
// the race window. See bug #8737.
|
|
$tasksToRun = $this->_getTasksToRun();
|
|
$updateResult = 0;
|
|
if (in_array($task, $tasksToRun, true)) {
|
|
$updateResult = $taskDao->updateLastRunTime($className, time());
|
|
}
|
|
|
|
if ($updateResult === false || $updateResult === 1) {
|
|
// DB doesn't support the get affected rows used inside update method, or one row was updated when we introduced a new last run time.
|
|
// Load and execute the task.
|
|
//
|
|
if (preg_match('/^[a-zA-Z0-9_.]+$/', $className)) {
|
|
// DEPRECATED as of 3.4.0: Use old class.name.style and import() function (pre-PSR classloading) pkp/pkp-lib#8186
|
|
// Strip off the package name(s) to get the base class name
|
|
$pos = strrpos($className, '.');
|
|
$baseClassName = $pos === false ? $className : substr($className, $pos + 1);
|
|
|
|
import($className);
|
|
$task = new $baseClassName($taskArgs);
|
|
} else {
|
|
$task = new $className($taskArgs);
|
|
if (!$task instanceof ScheduledTask) {
|
|
throw new \Exception("Scheduled task {$className} was an unexpected class!");
|
|
}
|
|
}
|
|
$task->execute();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse all scheduled tasks files and
|
|
* save the result object in database.
|
|
*/
|
|
private function _parseCrontab(): void
|
|
{
|
|
$xmlParser = new PKPXMLParser();
|
|
|
|
$taskFilesPath = [];
|
|
|
|
// Load all plugins so any plugin can register a crontab.
|
|
PluginRegistry::loadAllPlugins();
|
|
|
|
// Let plugins register their scheduled tasks too.
|
|
Hook::call('AcronPlugin::parseCronTab', [&$taskFilesPath]); // Reference needed.
|
|
|
|
// Add the default tasks file.
|
|
$taskFilesPath[] = 'registry/scheduledTasks.xml'; // TODO: make this a plugin setting, rather than assuming.
|
|
|
|
$tasks = [];
|
|
foreach ($taskFilesPath as $filePath) {
|
|
$tree = $xmlParser->parse($filePath);
|
|
|
|
if (!$tree) {
|
|
fatalError('Error parsing scheduled tasks XML file: ' . $filePath);
|
|
}
|
|
|
|
foreach ($tree->getChildren() as $task) {
|
|
$frequency = $task->getChildByName('frequency');
|
|
|
|
$args = ScheduledTaskHelper::getTaskArgs($task);
|
|
|
|
// Tasks without a frequency defined, or defined to zero, will run on every request.
|
|
// To avoid that happening (may cause performance problems) we
|
|
// setup a default period of time.
|
|
$setDefaultFrequency = true;
|
|
$minHoursRunPeriod = 24;
|
|
if ($frequency) {
|
|
$frequencyAttributes = $frequency->getAttributes();
|
|
if (is_array($frequencyAttributes)) {
|
|
foreach ($frequencyAttributes as $value) {
|
|
if ($value != 0) {
|
|
$setDefaultFrequency = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$tasks[] = [
|
|
'className' => $task->getAttribute('class'),
|
|
'frequency' => $setDefaultFrequency ? ['hour' => $minHoursRunPeriod] : $frequencyAttributes,
|
|
'args' => $args
|
|
];
|
|
}
|
|
}
|
|
|
|
// Store the object.
|
|
$this->updateSetting(0, 'crontab', $tasks, 'object');
|
|
}
|
|
|
|
/**
|
|
* Get all scheduled tasks that needs to be executed.
|
|
*/
|
|
private function _getTasksToRun(): array
|
|
{
|
|
$isEnabled = $this->getSetting(0, 'enabled');
|
|
if (!$isEnabled) {
|
|
return [];
|
|
}
|
|
|
|
$tasksToRun = [];
|
|
// Grab the scheduled scheduled tree
|
|
$scheduledTasks = $this->getSetting(0, 'crontab');
|
|
if (is_null($scheduledTasks)) {
|
|
$this->_parseCrontab();
|
|
$scheduledTasks = $this->getSetting(0, 'crontab');
|
|
}
|
|
|
|
foreach ($scheduledTasks as $task) {
|
|
// We don't allow tasks without frequency, see _parseCronTab().
|
|
$frequency = new XMLNode();
|
|
$frequency->setAttribute(key($task['frequency']), current($task['frequency']));
|
|
$canExecute = ScheduledTaskHelper::checkFrequency($task['className'], $frequency);
|
|
if ($canExecute) {
|
|
$tasksToRun[] = $task;
|
|
}
|
|
}
|
|
|
|
return $tasksToRun;
|
|
}
|
|
}
|
|
|
|
if (!PKP_STRICT_MODE) {
|
|
class_alias('\APP\plugins\generic\acron\AcronPlugin', '\AcronPlugin');
|
|
}
|