<?php namespace MailPoet\Cron; if (!defined('ABSPATH')) exit; use MailPoet\Router\Endpoints\CronDaemon as CronDaemonEndpoint; use MailPoet\Router\Router; use MailPoet\Settings\SettingsController; use MailPoet\Util\Security; use MailPoet\WP\Functions as WPFunctions; class CronHelper { const DAEMON_EXECUTION_LIMIT = 20; // seconds const DAEMON_REQUEST_TIMEOUT = 5; // seconds const DAEMON_SETTING = 'cron_daemon'; const DAEMON_STATUS_ACTIVE = 'active'; const DAEMON_STATUS_INACTIVE = 'inactive'; // Error codes const DAEMON_EXECUTION_LIMIT_REACHED = 1001; /** @var SettingsController */ private $settings; /** @var WPFunctions */ private $wp; public function __construct( SettingsController $settings, WPFunctions $wp ) { $this->settings = $settings; $this->wp = $wp; } public function getDaemonExecutionLimit() { $limit = $this->wp->applyFilters('mailpoet_cron_get_execution_limit', self::DAEMON_EXECUTION_LIMIT); return $limit; } public function getDaemonExecutionTimeout() { $limit = $this->getDaemonExecutionLimit(); $timeout = $limit * 1.75; return $this->wp->applyFilters('mailpoet_cron_get_execution_timeout', $timeout); } public function createDaemon($token) { $daemon = [ 'token' => $token, 'status' => self::DAEMON_STATUS_ACTIVE, 'run_accessed_at' => null, 'run_started_at' => null, 'run_completed_at' => null, 'last_error' => null, 'last_error_date' => null, ]; $this->saveDaemon($daemon); return $daemon; } public function restartDaemon($token) { return $this->createDaemon($token); } public function getDaemon() { return $this->settings->fetch(self::DAEMON_SETTING); } public function saveDaemonLastError($error) { $daemon = $this->getDaemon(); if ($daemon) { $daemon['last_error'] = $error; $daemon['last_error_date'] = time(); $this->saveDaemon($daemon); } } public function saveDaemonRunCompleted($runCompletedAt) { $daemon = $this->getDaemon(); if ($daemon) { $daemon['run_completed_at'] = $runCompletedAt; $this->saveDaemon($daemon); } } public function saveDaemon($daemon) { $daemon['updated_at'] = time(); $this->settings->set( self::DAEMON_SETTING, $daemon ); } public function deactivateDaemon($daemon) { // We do not need to deactivate an inactive daemon if (isset($daemon['status']) && $daemon['status'] === self::DAEMON_STATUS_INACTIVE) { return; } $daemon['status'] = self::DAEMON_STATUS_INACTIVE; $this->settings->set( self::DAEMON_SETTING, $daemon ); } public function createToken() { return Security::generateRandomString(); } public function pingDaemon() { $url = $this->getCronUrl( CronDaemonEndpoint::ACTION_PING_RESPONSE ); $result = $this->queryCronUrl($url); if (is_wp_error($result)) return $result->get_error_message(); $response = $this->wp->wpRemoteRetrieveBody($result); $response = substr(trim($response), -strlen(DaemonHttpRunner::PING_SUCCESS_RESPONSE)) === DaemonHttpRunner::PING_SUCCESS_RESPONSE ? DaemonHttpRunner::PING_SUCCESS_RESPONSE : $response; return $response; } public function validatePingResponse($response) { return $response === DaemonHttpRunner::PING_SUCCESS_RESPONSE; } public function accessDaemon($token) { $data = ['token' => $token]; $url = $this->getCronUrl( CronDaemonEndpoint::ACTION_RUN, $data ); $daemon = $this->getDaemon(); if (!$daemon) { throw new \LogicException('Daemon does not exist.'); } $daemon['run_accessed_at'] = time(); $this->saveDaemon($daemon); $result = $this->queryCronUrl($url); return $this->wp->wpRemoteRetrieveBody($result); } /** * @return bool|null */ public function isDaemonAccessible() { $daemon = $this->getDaemon(); if (!$daemon || !isset($daemon['run_accessed_at'])) { return null; } if ($daemon['run_accessed_at'] <= (int)$daemon['run_started_at']) { return true; } if ( $daemon['run_accessed_at'] + self::DAEMON_REQUEST_TIMEOUT < time() && $daemon['run_accessed_at'] > (int)$daemon['run_started_at'] ) { return false; } return null; } public function queryCronUrl($url) { $args = $this->wp->applyFilters( 'mailpoet_cron_request_args', [ 'blocking' => true, 'sslverify' => false, 'timeout' => self::DAEMON_REQUEST_TIMEOUT, 'user-agent' => 'MailPoet Cron', ] ); return $this->wp->wpRemotePost($url, $args); } public function getCronUrl($action, $data = false) { $url = Router::buildRequest( CronDaemonEndpoint::ENDPOINT, $action, $data ); $customCronUrl = $this->wp->applyFilters('mailpoet_cron_request_url', $url); return ($customCronUrl === $url) ? str_replace(home_url(), $this->getSiteUrl(), $url) : $customCronUrl; } public function getSiteUrl($siteUrl = false) { // additional check for some sites running inside a virtual machine or behind // proxy where there could be different ports (e.g., host:8080 => guest:80) if (!$siteUrl) { $siteUrl = defined('MAILPOET_CRON_SITE_URL') ? MAILPOET_CRON_SITE_URL : $this->wp->homeUrl(); } $parsedUrl = parse_url($siteUrl); if (!is_array($parsedUrl)) { throw new \Exception(__('Site URL is unreachable.', 'mailpoet')); } $callScheme = ''; if (isset($parsedUrl['scheme']) && ($parsedUrl['scheme'] === 'https')) { $callScheme = 'ssl://'; } // 1. if site URL does not contain a port, return the URL if (!isset($parsedUrl['port']) || empty($parsedUrl['port'])) return $siteUrl; // 2. if site URL contains valid port, try connecting to it $urlHost = $parsedUrl['host'] ?? ''; $fp = @fsockopen($callScheme . $urlHost, $parsedUrl['port'], $errno, $errstr, 1); if ($fp) return $siteUrl; // 3. if connection fails, attempt to connect the standard port derived from URL // schema $urlScheme = $parsedUrl['scheme'] ?? ''; $port = (strtolower($urlScheme) === 'http') ? 80 : 443; $fp = @fsockopen($callScheme . $urlHost, $port, $errno, $errstr, 1); if ($fp) return sprintf('%s://%s', $urlScheme, $urlHost); // 4. throw an error if all connection attempts failed throw new \Exception(__('Site URL is unreachable.', 'mailpoet')); } public function enforceExecutionLimit($timer) { $elapsedTime = microtime(true) - $timer; if ($elapsedTime >= $this->getDaemonExecutionLimit()) { throw new \Exception(__('Maximum execution time has been reached.', 'mailpoet'), self::DAEMON_EXECUTION_LIMIT_REACHED); } } }