diff --git a/application/config/migration.php b/application/config/migration.php index aebf9841dc..ce5a1727a3 100644 --- a/application/config/migration.php +++ b/application/config/migration.php @@ -22,7 +22,7 @@ | */ -$config['migration_version'] = 195; +$config['migration_version'] = 196; /* |-------------------------------------------------------------------------- diff --git a/application/controllers/Clublog.php b/application/controllers/Clublog.php index 83815ac012..ad61ec00a8 100644 --- a/application/controllers/Clublog.php +++ b/application/controllers/Clublog.php @@ -25,6 +25,10 @@ public function index() { public function upload() { $this->load->model('clublog_model'); + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + $users = $this->clublog_model->get_clublog_users(); foreach ($users as $user) { diff --git a/application/controllers/Cron.php b/application/controllers/Cron.php new file mode 100644 index 0000000000..af0c80d669 --- /dev/null +++ b/application/controllers/Cron.php @@ -0,0 +1,262 @@ +session->userdata('user_id') == '') { + echo "Maintenance Mode is active. Try again later.\n"; + redirect('user/login'); + } + + $this->load->model('cron_model'); + } + + public function index() { + + $this->load->model('user_model'); + if (!$this->user_model->authorize(99)) { + $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); + redirect('dashboard'); + } + + $this->load->helper('file'); + + $footerData = []; + $footerData['scripts'] = [ + 'assets/js/cronstrue.min.js?' . filemtime(realpath(__DIR__ . "/../../assets/js/cronstrue.min.js")), + 'assets/js/sections/cron.js?' . filemtime(realpath(__DIR__ . "/../../assets/js/sections/cron.js")) + ]; + + $data['page_title'] = "Cron Manager"; + $data['crons'] = $this->cron_model->get_crons(); + + $mastercron = array(); + $mastercron = $this->get_mastercron_status(); + $data['mastercron'] = $mastercron; + + $this->load->view('interface_assets/header', $data); + $this->load->view('cron/index'); + $this->load->view('interface_assets/footer', $footerData); + } + + public function run() { + + // This is the main function, which handles all crons, runs them if enabled and writes the 'next run' timestamp to the database + + // TODO Add an API Key to the cronjob to improve security? + + $crons = $this->cron_model->get_crons(); + + $status = 'pending'; + + foreach ($crons as $cron) { + if ($cron->enabled == 1) { + + // calculate the crons expression + $data = array( + 'expression' => $cron->expression, + 'timeZone' => null + ); + $this->load->library('CronExpression', $data); + + $cronjob = $this->cronexpression; + $dt = new DateTime(); + $isdue = $cronjob->isMatching($dt); + + $next_run = $cronjob->getNext(); + $next_run_date = date('Y-m-d H:i:s', $next_run); + $this->cron_model->set_next_run($cron->id, $next_run_date); + + if ($isdue == true) { + $isdue_result = 'true'; + + // TODO Add log_message level debug here to have logging for the cron manager + + echo "CRON: " . $cron->id . " -> is due: " . $isdue_result . "\n"; + echo "CRON: " . $cron->id . " -> RUNNING...\n"; + + $url = base_url() . $cron->function; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HEADER, false); + curl_setopt($ch, CURLOPT_USERAGENT, 'Wavelog Updater'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + $crun = curl_exec($ch); + curl_close($ch); + + if ($crun !== false) { + echo "CRON: " . $cron->id . " -> CURL Result: " . $crun . "\n"; + $status = 'healthy'; + } else { + echo "ERROR: Something went wrong with " . $cron->id . "\n"; + $status = 'failed'; + } + } else { + $isdue_result = 'false'; + echo "CRON: " . $cron->id . " -> is due: " . $isdue_result . " -> Next Run: " . $next_run_date . "\n"; + $status = 'healthy'; + } + } else { + echo 'CRON: ' . $cron->id . " is disabled. skipped..\n"; + $status = 'disabled'; + + // Set the next_run timestamp to null to indicate in the view/database that this cron is disabled + $this->cron_model->set_next_run($cron->id, null); + } + $this->cron_model->set_status($cron->id, $status); + $this->cronexpression = null; + } + + $datetime = new DateTime("now", new DateTimeZone('UTC')); + $datetime = $datetime->format('Ymd H:i:s'); + $this->optionslib->update('mastercron_last_run', $datetime , 'no'); + } + + public function editDialog() { + + $cron_query = $this->cron_model->cron(xss_clean($this->input->post('id', true))); + + $data['cron'] = $cron_query->row(); + $data['page_title'] = "Edit Cronjob"; + + $this->load->view('cron/edit', $data); + } + + public function edit() { + $this->load->model('user_model'); + if (!$this->user_model->authorize(99)) { + $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); + redirect('dashboard'); + } + + $id = xss_clean($this->input->post('cron_id', true)); + $description = xss_clean($this->input->post('cron_description', true)); + $expression = xss_clean($this->input->post('cron_expression', true)); + $enabled = xss_clean($this->input->post('cron_enabled', true)); + + $data = array( + 'expression' => $expression, + 'timeZone' => null + ); + $this->load->library('CronExpression', $data); + $cron = $this->cronexpression; + + if ($cron->isValid()) { + $this->cron_model->edit_cron($id, $description, $expression, $enabled); + $this->cronexpression = null; + + header("Content-type: application/json"); + echo json_encode(['success' => true, 'messagecategory' => 'success', 'message' => 'Changes saved for Cronjob "' . $id . '"']); + } else { + $this->session->set_flashdata('error', 'The Cron Expression you entered is not valid'); + $this->cronexpression = null; + + header("Content-type: application/json"); + echo json_encode(['success' => false, 'messagecategory' => 'error', 'message' => 'The expression "' . $expression . '" is not valid. Please try again.']); + } + } + + public function toogleEnableCronSwitch() { + + $id = xss_clean($this->input->post('id', true)); + $cron_enabled = xss_clean($this->input->post('checked', true)); + + if ($id ?? '' != '') { + $this->cron_model->set_cron_enabled($id, $cron_enabled); + $data['success'] = 1; + } else { + $data['success'] = 0; + $data['flashdata'] = 'Not allowed'; + } + echo json_encode($data); + } + + public function fetchCrons() { + $hres = []; + $result = $this->cron_model->get_crons(); + + foreach ($result as $cron) { + $single = (object) []; + $single->cron_id = $cron->id; + $single->cron_description = $cron->description; + $single->cron_status = $this->cronStatus2html($cron->enabled, $cron->status); + $single->cron_expression = $this->cronExpression2html($cron->expression); + $single->cron_last_run = $cron->last_run ?? 'never'; + $single->cron_next_run = ($cron->enabled == '1') ? ($cron->next_run ?? 'calculating..') : 'never'; + $single->cron_edit = $this->cronEdit2html($cron->id); + $single->cron_enabled = $this->cronEnabled2html($cron->id, $cron->enabled); + array_push($hres, $single); + } + echo json_encode($hres); + } + + private function cronStatus2html($enabled, $status) { + if ($enabled == '1') { + if ($status == 'healthy') { + $htmlret = 'healthy'; + } else { + $htmlret = '' . $status . ''; + } + } else { + $htmlret = 'disabled'; + } + return $htmlret; + } + + private function cronExpression2html($expression) { + $htmlret = '' . $expression . ''; + return $htmlret; + } + + private function cronEdit2html($id) { + $htmlret = ''; + return $htmlret; + } + + private function cronEnabled2html($id, $enabled) { + if ($enabled == '1') { + $checked = 'checked'; + } else { + $checked = ''; + } + $htmlret = '
'; + return $htmlret; + } + + private function get_mastercron_status() { + $warning_timelimit_seconds = 120; // yellow - warning please check + $error_timelimit_seconds = 600; // red - "not running" + + $result = array(); + + $last_run = $this->optionslib->get_option('mastercron_last_run') ?? null; + + if ($last_run != null) { + $timestamp_last_run = DateTime::createFromFormat('Ymd H:i:s', $last_run, new DateTimeZone('UTC')); + $now = new DateTime(); + $diff = $now->getTimestamp() - $timestamp_last_run->getTimestamp(); + + if ($diff >= 0 && $diff <= $warning_timelimit_seconds) { + $result['status'] = 'OK'; + $result['status_class'] = 'success'; + } else { + if ($diff <= $error_timelimit_seconds) { + $result['status'] = 'Last run occurred more than ' . $warning_timelimit_seconds . ' seconds ago.
Please check your master cron! It should run every minute (* * * * *).'; + $result['status_class'] = 'warning'; + } else { + $result['status'] = 'Last run occurred more than ' . ($error_timelimit_seconds / 60) . ' minutes ago.
Seems like your Mastercron isn\'t running!
It should run every minute (* * * * *).'; + $result['status_class'] = 'danger'; + } + } + } else { + $result['status'] = 'Not running'; + $result['status_class'] = 'danger'; + } + + return $result; + } + +} diff --git a/application/controllers/Debug.php b/application/controllers/Debug.php index 2e06ccf877..8857c7b4de 100644 --- a/application/controllers/Debug.php +++ b/application/controllers/Debug.php @@ -21,6 +21,7 @@ public function index() { $this->load->model('Logbook_model'); $this->load->model('Debug_model'); $this->load->model('Stations'); + $this->load->model('cron_model'); $footerData = []; $footerData['scripts'] = ['assets/js/sections/debug.js']; @@ -62,6 +63,14 @@ public function index() { $data['userdata_status'] = $userdata_status; } + $data['dxcc_update'] = $this->cron_model->cron('update_dxcc')->row(); + $data['dok_update'] = $this->cron_model->cron('update_update_dok')->row(); + $data['lotw_user_update'] = $this->cron_model->cron('update_lotw_users')->row(); + $data['pota_update'] = $this->cron_model->cron('update_update_pota')->row(); + $data['scp_update'] = $this->cron_model->cron('update_update_clublog_scp')->row(); + $data['sota_update'] = $this->cron_model->cron('update_update_sota')->row(); + $data['wwff_update'] = $this->cron_model->cron('update_update_wwff')->row(); + $data['page_title'] = "Debug"; $this->load->view('interface_assets/header', $data); diff --git a/application/controllers/Eqsl.php b/application/controllers/Eqsl.php index a9df209298..790a929d2c 100644 --- a/application/controllers/Eqsl.php +++ b/application/controllers/Eqsl.php @@ -713,6 +713,11 @@ public function mark_all_sent() { * Used for CRON job */ public function sync() { + + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + ini_set('memory_limit', '-1'); set_time_limit(0); $this->load->model('eqslmethods_model'); diff --git a/application/controllers/Hrdlog.php b/application/controllers/Hrdlog.php index 2b3182b49f..0a0b09bdac 100644 --- a/application/controllers/Hrdlog.php +++ b/application/controllers/Hrdlog.php @@ -25,6 +25,10 @@ function __construct() public function upload() { $this->setOptions(); + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + $this->load->model('logbook_model'); $station_ids = $this->logbook_model->get_station_id_with_hrdlog_code(); diff --git a/application/controllers/Lotw.php b/application/controllers/Lotw.php index eb2eba33f0..2bae0a643c 100644 --- a/application/controllers/Lotw.php +++ b/application/controllers/Lotw.php @@ -198,6 +198,10 @@ public function lotw_upload() { echo "You must install php OpenSSL for LoTW functions to work"; } + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + // Get Station Profile Data $this->load->model('Stations'); diff --git a/application/controllers/Qrz.php b/application/controllers/Qrz.php index 6eac702535..2f2ff77f7f 100644 --- a/application/controllers/Qrz.php +++ b/application/controllers/Qrz.php @@ -69,6 +69,10 @@ public function qrz_apitest() { public function upload() { $this->setOptions(); + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + $this->load->model('logbook_model'); $station_ids = $this->logbook_model->get_station_id_with_qrz_api(); @@ -260,6 +264,8 @@ function download($user_id_to_load = null, $lastqrz = null, $show_views = false) $this->load->model('user_model'); $this->load->model('logbook_model'); + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); $api_keys = $this->logbook_model->get_qrz_apikeys(); diff --git a/application/controllers/Update.php b/application/controllers/Update.php index 542a4e3808..d0c4aceceb 100644 --- a/application/controllers/Update.php +++ b/application/controllers/Update.php @@ -20,6 +20,9 @@ function __construct() public function index() { + $this->load->model('user_model'); + if(!$this->user_model->authorize(2)) { $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); redirect('dashboard'); } + $data['page_title'] = "Updates"; $this->load->view('interface_assets/header', $data); $this->load->view('update/index'); @@ -176,6 +179,11 @@ public function dxcc_prefixes() { // Updates the DXCC & Exceptions from the Club Log Cty.xml file. public function dxcc() { + + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + $this->update_status("Downloading file"); // give it 10 minutes... @@ -236,9 +244,6 @@ public function update_status($done=""){ $html .= "Dxcc Prefixes: ".$this->db->count_all('dxcc_prefixes')."
"; } else { $html = $done."....
"; - $datetime = new DateTime("now", new DateTimeZone('UTC')); - $datetime = $datetime->format('Ymd h:i'); - $this->optionslib->update('dxcc_clublog_update', $datetime , 'no'); } file_put_contents($this->make_update_path("status.html"), $html); @@ -302,6 +307,11 @@ public function check_missing_grid($all = false){ } public function update_clublog_scp() { + + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + $strFile = $this->make_update_path("clublog_scp.txt"); $url = "https://cdn.clublog.org/clublog.scp.gz"; set_time_limit(300); @@ -320,9 +330,6 @@ public function update_clublog_scp() { if ($nCount > 0) { echo "DONE: " . number_format($nCount) . " callsigns loaded"; - $datetime = new DateTime("now", new DateTimeZone('UTC')); - $datetime = $datetime->format('Ymd h:i'); - $this->optionslib->update('scp_update', $datetime , 'no'); } else { echo "FAILED: Empty file"; } @@ -352,6 +359,11 @@ public function download_lotw_users() { } public function lotw_users() { + + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + $mtime = microtime(); $mtime = explode(" ",$mtime); $mtime = $mtime[1] + $mtime[0]; @@ -390,9 +402,6 @@ public function lotw_users() { $totaltime = ($endtime - $starttime); echo "This page was created in ".$totaltime." seconds
"; echo "Records inserted: " . $i . "
"; - $datetime = new DateTime("now", new DateTimeZone('UTC')); - $datetime = $datetime->format('Ymd h:i'); - $this->optionslib->update('lotw_users_update', $datetime , 'no'); } public function lotw_check() { @@ -412,6 +421,11 @@ public function lotw_check() { * Used for autoupdating the DOK file which is used in the QSO entry dialog for autocompletion. */ public function update_dok() { + + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + $contents = file_get_contents('https://www.df2et.de/cqrlog/dok_and_sdok.txt', true); if($contents === FALSE) { @@ -424,9 +438,6 @@ public function update_dok() { if ($nCount > 0) { echo "DONE: " . number_format($nCount) . " DOKs and SDOKs saved"; - $datetime = new DateTime("now", new DateTimeZone('UTC')); - $datetime = $datetime->format('Ymd h:i'); - $this->optionslib->update('dok_file_update', $datetime , 'no'); } else { echo"FAILED: Empty file"; } @@ -440,6 +451,11 @@ public function update_dok() { * Used for autoupdating the SOTA file which is used in the QSO entry dialog for autocompletion. */ public function update_sota() { + + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + $csvfile = 'https://www.sotadata.org.uk/summitslist.csv'; $sotafile = './assets/json/sota.txt'; @@ -474,9 +490,6 @@ public function update_sota() { if ($nCount > 0) { echo "DONE: " . number_format($nCount) . " SOTA's saved"; - $datetime = new DateTime("now", new DateTimeZone('UTC')); - $datetime = $datetime->format('Ymd h:i'); - $this->optionslib->update('sota_file_update', $datetime , 'no'); } else { echo"FAILED: Empty file"; } @@ -486,6 +499,11 @@ public function update_sota() { * Pulls the WWFF directory for autocompletion in QSO dialogs */ public function update_wwff() { + + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + $csvfile = 'https://wwff.co/wwff-data/wwff_directory.csv'; $wwfffile = './assets/json/wwff.txt'; @@ -524,15 +542,17 @@ public function update_wwff() { if ($nCount > 0) { echo "DONE: " . number_format($nCount) . " WWFF's saved"; - $datetime = new DateTime("now", new DateTimeZone('UTC')); - $datetime = $datetime->format('Ymd h:i'); - $this->optionslib->update('wwff_file_update', $datetime , 'no'); } else { echo"FAILED: Empty file"; } } public function update_pota() { + + // set the last run in cron table for the correct cron id + $this->load->model('cron_model'); + $this->cron_model->set_last_run($this->router->class.'_'.$this->router->method); + $csvfile = 'https://pota.app/all_parks.csv'; $potafile = './assets/json/pota.txt'; @@ -570,9 +590,6 @@ public function update_pota() { if ($nCount > 0) { echo "DONE: " . number_format($nCount) . " POTA's saved"; - $datetime = new DateTime("now", new DateTimeZone('UTC')); - $datetime = $datetime->format('Ymd h:i'); - $this->optionslib->update('pota_file_update', $datetime , 'no'); } else { echo"FAILED: Empty file"; } diff --git a/application/libraries/CronExpression.php b/application/libraries/CronExpression.php new file mode 100644 index 0000000000..439f74883c --- /dev/null +++ b/application/libraries/CronExpression.php @@ -0,0 +1,424 @@ + 0, + 'mon' => 1, + 'tue' => 2, + 'wed' => 3, + 'thu' => 4, + 'fri' => 5, + 'sat' => 6 + ]; + + /** + * Month name look-up table + */ + private const MONTH_NAMES = [ + 'jan' => 1, + 'feb' => 2, + 'mar' => 3, + 'apr' => 4, + 'may' => 5, + 'jun' => 6, + 'jul' => 7, + 'aug' => 8, + 'sep' => 9, + 'oct' => 10, + 'nov' => 11, + 'dec' => 12 + ]; + + /** + * Value boundaries + */ + private const VALUE_BOUNDARIES = [ + 0 => [ + 'min' => 0, + 'max' => 59, + 'mod' => 1 + ], + 1 => [ + 'min' => 0, + 'max' => 23, + 'mod' => 1 + ], + 2 => [ + 'min' => 1, + 'max' => 31, + 'mod' => 1 + ], + 3 => [ + 'min' => 1, + 'max' => 12, + 'mod' => 1 + ], + 4 => [ + 'min' => 0, + 'max' => 7, + 'mod' => 0 + ] + ]; + + /** + * @var DateTimeZone|null + */ + protected readonly ?DateTimeZone $timeZone; + + /** + * @var array|null + */ + protected readonly ?array $registers; + + /** + * @var string + */ + protected readonly string $expression; + + /** + * @param string $expression a cron expression, e.g. "* * * * *" + * @param DateTimeZone|null $timeZone time zone objectstring $expression, DateTimeZone $timeZone = null + */ + public function __construct($data) { + $this->timeZone = $data['timeZone']; + $this->expression = $data['expression']; + + try { + $this->registers = $this->parse($data['expression']); + } catch (Exception $e) { + $this->registers = null; + } + } + + /** + * Whether current cron expression has been parsed successfully + * + * @return bool + */ + public function isValid(): bool { + return null !== $this->registers; + } + + /** + * Match either "now", a given date/time object or a timestamp against current cron expression + * + * @param mixed $when a DateTime object, a timestamp (int), or "now" if not set + * @return bool + * @throws Exception + */ + public function isMatching($when = null): bool { + if (false === ($when instanceof DateTimeInterface)) { + $when = (new DateTime())->setTimestamp($when === null ? time() : $when); + } + + if ($this->timeZone !== null) { + $when->setTimezone($this->timeZone); + } + + return $this->isValid() && $this->match(sscanf($when->format('i G j n w'), '%d %d %d %d %d')); + } + + /** + * Calculate next matching timestamp + * + * @param mixed $start a DateTime object, a timestamp (int) or "now" if not set + * @return int|bool next matching timestamp, or false on error + * @throws Exception + */ + public function getNext($start = null) { + if ($this->isValid()) { + $next = $this->toDateTime($start); + + do { + $pos = sscanf($next->format('i G j n Y w'), '%d %d %d %d %d %d'); + } while ($this->increase($next, $pos)); + + return $next->getTimestamp(); + } + + return false; + } + + /** + * @param mixed $start a DateTime object, a timestamp (int) or "now" if not set + * @return DateTime + */ + private function toDateTime($start): DateTime { + if ($start instanceof DateTimeInterface) { + $next = $start; + } elseif ((int)$start > 0) { + $next = new DateTime('@' . $start); + } else { + $next = new DateTime('@' . time()); + } + + $next->setTimestamp($next->getTimeStamp() - $next->getTimeStamp() % 60); + $next->setTimezone($this->timeZone ?: new DateTimeZone(date_default_timezone_get())); + + if ($this->isMatching($next)) { + $next->modify('+1 minute'); + } + + return $next; + } + + /** + * Increases the timestamp in step sizes depending on which segment(s) of the cron pattern are matching. + * Returns FALSE if the cron pattern is matching and thus no further cycle is required. + * + * @param DateTimeInterface $next + * @param array $pos + * @return bool + */ + private function increase(DateTimeInterface $next, array $pos): bool { + switch (true) { + case false === isset($this->registers[3][$pos[3]]): + // next month, reset day/hour/minute + $next->setTime(0, 0); + $next->setDate($pos[4], $pos[3], 1); + $next->modify('+1 month'); + return true; + + case false === (isset($this->registers[2][$pos[2]]) && isset($this->registers[4][$pos[5]])): + // next day, reset hour/minute + $next->setTime(0, 0); + $next->modify('+1 day'); + return true; + + case false === isset($this->registers[1][$pos[1]]): + // next hour, reset minute + $next->setTime($pos[1], 0); + $next->modify('+1 hour'); + return true; + + case false === isset($this->registers[0][$pos[0]]): + // next minute + $next->modify('+1 minute'); + return true; + + default: + // all segments are matching + return false; + } + } + + /** + * @param array $segments + * @return bool + */ + private function match(array $segments): bool { + foreach ($this->registers as $i => $item) { + if (isset($item[(int)$segments[$i]]) === false) { + return false; + } + } + + return true; + } + + /** + * Parse whole cron expression + * + * @param string $expression + * @return array + * @throws Exception + */ + private function parse(string $expression): array { + $segments = preg_split('/\s+/', trim($expression)); + + if (is_array($segments) && sizeof($segments) === 5) { + $registers = array_fill(0, 5, []); + + foreach ($segments as $index => $segment) { + $this->parseSegment($registers[$index], $index, $segment); + } + + $this->validateDate($registers); + + if (isset($registers[4][7])) { + $registers[4][0] = true; + } + + return $registers; + } + + throw new Exception('invalid number of segments'); + } + + /** + * Parse one segment of a cron expression + * + * @param array $register + * @param int $index + * @param string $segment + * @throws Exception + */ + private function parseSegment(array &$register, int $index, string $segment): void { + $allowed = [false, false, false, self::MONTH_NAMES, self::WEEKDAY_NAMES]; + + // month names, weekdays + if ($allowed[$index] !== false && isset($allowed[$index][strtolower($segment)])) { + // cannot be used together with lists or ranges + $register[$allowed[$index][strtolower($segment)]] = true; + } else { + // split up current segment into single elements, e.g. "1,5-7,*/2" => [ "1", "5-7", "*/2" ] + foreach (explode(',', $segment) as $element) { + $this->parseElement($register, $index, $element); + } + } + } + + /** + * @param array $register + * @param int $index + * @param string $element + * @throws Exception + */ + private function parseElement(array &$register, int $index, string $element): void { + $step = 1; + $segments = explode('/', $element); + + if (sizeof($segments) > 1) { + $this->validateStepping($segments, $index); + + $element = (string)$segments[0]; + $step = (int)$segments[1]; + } + + if (is_numeric($element)) { + $this->validateValue($element, $index, $step); + $register[intval($element)] = true; + } else { + $this->parseRange($register, $index, $element, $step); + } + } + + /** + * Parse range of values, e.g. "5-10" + * + * @param array $register + * @param int $index + * @param string $range + * @param int $stepping + * @throws Exception + */ + private function parseRange(array &$register, int $index, string $range, int $stepping): void { + if ($range === '*') { + $rangeArr = [self::VALUE_BOUNDARIES[$index]['min'], self::VALUE_BOUNDARIES[$index]['max']]; + } else { + $rangeArr = explode('-', $range); + } + + $this->validateRange($rangeArr, $index); + $this->fillRange($register, $index, $rangeArr, $stepping); + } + + /** + * @param array $register + * @param int $index + * @param array $range + * @param int $stepping + */ + private function fillRange(array &$register, int $index, array $range, int $stepping): void { + $boundary = self::VALUE_BOUNDARIES[$index]['max'] + self::VALUE_BOUNDARIES[$index]['mod']; + $length = $range[1] - $range[0]; + + for ($i = 0; $i <= $length; $i += $stepping) { + $register[($range[0] + $i) % $boundary] = true; + } + } + + /** + * Validate whether a given range of values exceeds allowed value boundaries + * + * @param array $range + * @param int $index + * @throws Exception + */ + private function validateRange(array $range, int $index): void { + if (sizeof($range) !== 2) { + throw new Exception('invalid range notation'); + } + + foreach ($range as $value) { + $this->validateValue($value, $index); + } + + if ($range[0] > $range[1]) { + throw new Exception('lower value in range is larger than upper value'); + } + } + + /** + * @param string $value + * @param int $index + * @param int $step + * @throws Exception + */ + private function validateValue(string $value, int $index, int $step = 1): void { + if (false === ctype_digit($value)) { + throw new Exception('non-integer value'); + } + + if ( + intval($value) < self::VALUE_BOUNDARIES[$index]['min'] || + intval($value) > self::VALUE_BOUNDARIES[$index]['max'] + ) { + throw new Exception('value out of boundary'); + } + + if ($step !== 1) { + throw new Exception('invalid combination of value and stepping notation'); + } + } + + /** + * @param array $segments + * @param int $index + * @throws Exception + */ + private function validateStepping(array $segments, int $index): void { + if (sizeof($segments) !== 2) { + throw new Exception('invalid stepping notation'); + } + + if ((int)$segments[1] < 1 || (int)$segments[1] > self::VALUE_BOUNDARIES[$index]['max']) { + throw new Exception('stepping out of allowed range'); + } + } + + /** + * @param array $segments + * @throws Exception + */ + private function validateDate(array $segments): void { + $year = date('Y'); + + for ($y = 0; $y < 27; $y++) { + foreach (array_keys($segments[3]) as $month) { + foreach (array_keys($segments[2]) as $day) { + if (false === checkdate($month, $day, $year + $y)) { + continue; + } + + if (false === isset($segments[date('w', strtotime(sprintf('%d-%d-%d', $year + $y, $month, $day)))])) { + continue; + } + + return; + } + } + } + + throw new Exception('no date ever can match the given combination of day/month/weekday'); + } +} diff --git a/application/migrations/196_cron_table.php b/application/migrations/196_cron_table.php new file mode 100644 index 0000000000..f8b1dfce3d --- /dev/null +++ b/application/migrations/196_cron_table.php @@ -0,0 +1,216 @@ +db->table_exists('cron')) { + + // define the structure of the new cron table + $this->dbforge->add_field(array( + 'id' => array( + 'type' => 'VARCHAR', + 'constraint' => '255', + 'null' => FALSE, + ), + 'enabled' => array( + 'type' => 'TINYINT', + 'constraint' => '1', + 'null' => FALSE, + ), + 'status' => array( + 'type' => 'VARCHAR', + 'constraint' => '255', + 'null' => TRUE, + ), + 'description' => array( + 'type' => 'VARCHAR', + 'constraint' => '255', + 'null' => TRUE, + ), + 'function' => array( + 'type' => 'VARCHAR', + 'constraint' => '255', + 'null' => FALSE, + ), + 'expression' => array( + 'type' => 'VARCHAR', + 'constraint' => '100', + 'null' => TRUE, + ), + 'last_run' => array( + 'type' => 'TIMESTAMP', + 'null' => TRUE, + ), + 'next_run' => array( + 'type' => 'TIMESTAMP', + 'null' => TRUE, + ), + 'modified' => array( + 'type' => 'TIMESTAMP', + 'null' => TRUE, + ), + )); + + // we set the key for the id, in this case the id is not numerical + $this->dbforge->add_key('id', TRUE); + + // now we can create the new table + $this->dbforge->create_table('cron'); + + // to transfer data for the file updates we load the optionslib library + $this->load->library('OptionsLib'); + + // and we fill the table with the cronjobs + $data = array( + array( + 'id' => 'clublog_upload', + 'enabled' => '0', + 'status' => 'pending', + 'description' => 'Upload QSOs to Clublog', + 'function' => 'index.php/clublog/upload', + 'expression' => '3 */6 * * *', + 'last_run' => null, + 'next_run' => null + ), + array( + 'id' => 'lotw_lotw_upload', + 'enabled' => '0', + 'status' => 'pending', + 'description' => 'Upload QSOs to LoTW', + 'function' => 'index.php/lotw/lotw_upload', + 'expression' => '0 */1 * * *', + 'last_run' => null, + 'next_run' => null + ), + array( + 'id' => 'qrz_upload', + 'enabled' => '0', + 'status' => 'pending', + 'description' => 'Upload QSOs to QRZ', + 'function' => 'index.php/qrz/upload', + 'expression' => '6 */6 * * *', + 'last_run' => null, + 'next_run' => null + ), + array( + 'id' => 'qrz_download', + 'enabled' => '0', + 'status' => 'pending', + 'description' => 'Download QSOs from QRZ', + 'function' => 'index.php/qrz/download', + 'expression' => '18 */6 * * *', + 'last_run' => null, + 'next_run' => null + ), + array( + 'id' => 'hrdlog_upload', + 'enabled' => '0', + 'status' => 'pending', + 'description' => 'Upload QSOs to HRD', + 'function' => 'index.php/hrdlog/upload', + 'expression' => '12 */6 * * *', + 'last_run' => null, + 'next_run' => null + ), + array( + 'id' => 'eqsl_sync', + 'enabled' => '0', + 'status' => 'pending', + 'description' => 'Upload/download QSOs to/from Eqsl', + 'function' => 'index.php/eqsl/sync', + 'expression' => '9 */6 * * *', + 'last_run' => null, + 'next_run' => null + ), + array( + 'id' => 'update_lotw_users', + 'enabled' => '1', + 'status' => 'pending', + 'description' => 'Update LOTW Users Activity', + 'function' => 'index.php/update/lotw_users', + 'expression' => '10 1 * * 1', + 'last_run' => ($this->optionslib->get_option('lotw_users_update') ? date("Y-m-d H:i", strtotime($this->optionslib->get_option('lotw_users_update'))) : null), + 'next_run' => null + ), + array( + 'id' => 'update_update_clublog_scp', + 'enabled' => '1', + 'status' => 'pending', + 'description' => 'Update Clublog SCP Database File', + 'function' => 'index.php/update/update_clublog_scp', + 'expression' => '0 0 * * 0', + 'last_run' => ($this->optionslib->get_option('scp_update') ? date("Y-m-d H:i", strtotime($this->optionslib->get_option('scp_update'))) : null), + 'next_run' => null + ), + array( + 'id' => 'update_update_dok', + 'enabled' => '1', + 'status' => 'pending', + 'description' => 'Update DOK File', + 'function' => 'index.php/update/update_dok', + 'expression' => '0 0 1 * *', + 'last_run' => ($this->optionslib->get_option('dok_file_update') ? date("Y-m-d H:i", strtotime($this->optionslib->get_option('dok_file_update'))) : null), + 'next_run' => null + ), + array( + 'id' => 'update_update_sota', + 'enabled' => '1', + 'status' => 'pending', + 'description' => 'Update SOTA File', + 'function' => 'index.php/update/update_sota', + 'expression' => '5 0 1 * *', + 'last_run' => ($this->optionslib->get_option('sota_file_update') ? date("Y-m-d H:i", strtotime($this->optionslib->get_option('sota_file_update'))) : null), + 'next_run' => null + ), + array( + 'id' => 'update_update_wwff', + 'enabled' => '1', + 'status' => 'pending', + 'description' => 'Update WWFF File', + 'function' => 'index.php/update/update_wwff', + 'expression' => '10 0 1 * *', + 'last_run' => ($this->optionslib->get_option('wwff_file_update') ? date("Y-m-d H:i", strtotime($this->optionslib->get_option('wwff_file_update'))) : null), + 'next_run' => null + ), + array( + 'id' => 'update_update_pota', + 'enabled' => '1', + 'status' => 'pending', + 'description' => 'Update POTA File', + 'function' => 'index.php/update/update_pota', + 'expression' => '15 0 1 * *', + 'last_run' => ($this->optionslib->get_option('pota_file_update') ? date("Y-m-d H:i", strtotime($this->optionslib->get_option('pota_file_update'))) : null), + 'next_run' => null + ), + array( + 'id' => 'update_dxcc', + 'enabled' => '1', + 'status' => 'pending', + 'description' => 'Update DXCC data', + 'function' => 'index.php/update/dxcc', + 'expression' => '20 0 1 */2 *', + 'last_run' => ($this->optionslib->get_option('dxcc_clublog_update') ? date("Y-m-d H:i", strtotime($this->optionslib->get_option('dxcc_clublog_update'))) : null), + 'next_run' => null + ), + ); + $this->db->insert_batch('cron', $data); + + // since we transfered the source for the file update timestamps we don't need this options anymore + $this->db->delete('options', array('option_name' => 'lotw_users_update')); + $this->db->delete('options', array('option_name' => 'scp_update')); + $this->db->delete('options', array('option_name' => 'dok_file_update')); + $this->db->delete('options', array('option_name' => 'sota_file_update')); + $this->db->delete('options', array('option_name' => 'wwff_file_update')); + $this->db->delete('options', array('option_name' => 'pota_file_update')); + $this->db->delete('options', array('option_name' => 'dxcc_clublog_update')); + } + } + + public function down() { + + $this->dbforge->drop_table('cron'); + + } +} diff --git a/application/models/Cron_model.php b/application/models/Cron_model.php new file mode 100644 index 0000000000..c616382f30 --- /dev/null +++ b/application/models/Cron_model.php @@ -0,0 +1,93 @@ +db->from('cron'); + + $results = array(); + + $results = $this->db->get()->result(); + + return $results; + } + + // get details for a specific cron + function cron($id) { + + $clean_id = $this->security->xss_clean($id); + + $this->db->where('id', $clean_id); + + return $this->db->get('cron'); + } + + // set the modified timestamp + function set_modified($cron) { + $data = array( + 'modified' => date('Y-m-d H:i:s') + ); + + $this->db->where('id', $cron); + $this->db->update('cron', $data); + } + + // set a new status for the cron + function set_status($cron, $status) { + $data = array( + 'status' => $status + ); + + $this->db->where('id', $cron); + $this->db->update('cron', $data); + } + + // set the last run + function set_last_run($cron) { + $data = array( + 'last_run' => date('Y-m-d H:i:s') + ); + + $this->db->where('id', $cron); + $this->db->update('cron', $data); + } + + // set the calculated next run + function set_next_run($cron,$timestamp) { + $data = array( + 'next_run' => $timestamp + ); + + $this->db->where('id', $cron); + $this->db->update('cron', $data); + } + + // set the cron enabled flag + function set_cron_enabled($cron, $cron_enabled) { + $data = array ( + 'enabled' => ($cron_enabled === 'true' ? 1 : 0), + 'status' => ($cron_enabled === 'true' ? 'pending' : 'disabled'), + ); + + $this->db->where('id', $cron); + $this->db->update('cron', $data); + + $this->set_modified($cron); + } + + // set the edited details for a cron + function edit_cron($id, $description, $expression, $enabled) { + + $data = array ( + 'description' => $description, + 'expression' => $expression, + 'enabled' => ($enabled === 'true' ? 1 : 0) + ); + + $this->db->where('id', $id); + $this->db->update('cron', $data); + + $this->set_modified($id); + } +} diff --git a/application/views/cron/edit.php b/application/views/cron/edit.php new file mode 100644 index 0000000000..d69afe2c76 --- /dev/null +++ b/application/views/cron/edit.php @@ -0,0 +1,78 @@ + \ No newline at end of file diff --git a/application/views/cron/index.php b/application/views/cron/index.php new file mode 100644 index 0000000000..964f4dd19a --- /dev/null +++ b/application/views/cron/index.php @@ -0,0 +1,97 @@ +
+
+

+ + + +
+
+ How it works +
+
+
+
+

+ The Cron Manager assists the administrator in managing cron jobs without requiring CLI access. +

+

+ To execute cron jobs based on the data below, remove all old cron jobs and create a new one: +

+
+
* * * * * curl --silent index.php/cron/run &>/dev/null
+
+
+
+
+ Status Master-Cron: +
+
+
+
+
+ +
+
+ Cron List +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDDescriptionStatusIntervallLast RunNext RunEditI/O
id; ?>description; ?>enabled == '1') { + if ($cron->status == 'healthy') { ?> + healthy + status == 'failed') { ?> + failed + + status; ?> + + + disabled + + ' . $cron->expression . ''; ?>last_run ?? 'never'; ?>enabled == '1') { + echo $cron->next_run ?? 'never'; + } else { + echo 'never'; + } ?> +
enabled ?? '0') { + echo 'checked'; + } ?>>
+
+
+ +
+

Your Mastercron isn't running.
Copy the cron above to a external cron service or into your server's cron to use this cron manager.

+

On a basic linux server with shell access use this command to edit your crons:

crontab -e

+
+ +
+
+
\ No newline at end of file diff --git a/application/views/debug/index.php b/application/views/debug/index.php index 67ef0ef663..75aeae2b5c 100644 --- a/application/views/debug/index.php +++ b/application/views/debug/index.php @@ -462,38 +462,38 @@ DXCC update from Club Log - optionslib->get_option('dxcc_clublog_update') ?? '') == '' ? '' : date($custom_date_format, strtotime($this->optionslib->get_option('dxcc_clublog_update') ?? '')) . ' ' . date("h:i", strtotime($this->optionslib->get_option('dxcc_clublog_update') ?? ''))) ?> + last_run ?? 'never'; ?> Update DOK file download - optionslib->get_option('dok_file_update') ?? '') == '' ? '' : date($custom_date_format, strtotime($this->optionslib->get_option('dok_file_update') ?? '')) . ' ' . date("h:i", strtotime($this->optionslib->get_option('dok_file_update') ?? ''))) ?> + last_run ?? 'never'; ?> Update LoTW users download - optionslib->get_option('lotw_users_update') ?? '') == '' ? '' : date($custom_date_format, strtotime($this->optionslib->get_option('lotw_users_update') ?? '')) . ' ' . date("h:i", strtotime($this->optionslib->get_option('lotw_users_update') ?? ''))) ?> + last_run ?? 'never'; ?> Update POTA file download - optionslib->get_option('pota_file_update') ?? '') == '' ? '' : date($custom_date_format, strtotime($this->optionslib->get_option('pota_file_update') ?? '')) . ' ' . date("h:i", strtotime($this->optionslib->get_option('pota_file_update') ?? ''))) ?> + last_run ?? 'never'; ?> Update SCP file download - optionslib->get_option('scp_update') ?? '') == '' ? '' : date($custom_date_format, strtotime($this->optionslib->get_option('scp_update') ?? '')) . ' ' . date("h:i", strtotime($this->optionslib->get_option('scp_update') ?? ''))) ?> + last_run ?? 'never'; ?> Update SOTA file download - optionslib->get_option('sota_file_update') ?? '') == '' ? '' : date($custom_date_format, strtotime($this->optionslib->get_option('sota_file_update') ?? '')) . ' ' . date("h:i", strtotime($this->optionslib->get_option('sota_file_update') ?? ''))) ?> + last_run ?? 'never'; ?> Update WWFF file download - optionslib->get_option('wwff_file_update') ?? '') == '' ? '' : date($custom_date_format, strtotime($this->optionslib->get_option('wwff_file_update') ?? '')) . ' ' . date("h:i", strtotime($this->optionslib->get_option('wwff_file_update') ?? ''))) ?> + last_run ?? 'never'; ?> Update diff --git a/application/views/interface_assets/footer.php b/application/views/interface_assets/footer.php index e3d814c96a..0882f62f75 100644 --- a/application/views/interface_assets/footer.php +++ b/application/views/interface_assets/footer.php @@ -106,6 +106,11 @@ function getDataTablesLanguageUrl() { + +uri->segment(1) == "cron") { ?> + + + uri->segment(1) == "options") { ?>