diff --git a/.gitignore b/.gitignore index 9c704873a..aa0eb77ee 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,14 @@ /application/config/config.php /application/config/managed.php /application/config/managed.sample.php +/.env +/PluginDirectory/.env /application/cache/* !/application/cache/index.html !/application/cache/.htaccess /application/logs/*.php +/application/plugins/* +!/application/plugins/index.html /uploads/*.adi /uploads/*.ADI /uploads/*.tq8 diff --git a/application/config/migration.php b/application/config/migration.php index 5bb94b1c6..a691c9281 100644 --- a/application/config/migration.php +++ b/application/config/migration.php @@ -22,7 +22,7 @@ | */ -$config['migration_version'] = 267; +$config['migration_version'] = 271; /* |-------------------------------------------------------------------------- diff --git a/application/controllers/Api.php b/application/controllers/Api.php index 8b8ae5432..9d75931ac 100644 --- a/application/controllers/Api.php +++ b/application/controllers/Api.php @@ -1575,7 +1575,9 @@ function recent_qsos($public_slug = null, $limit = 10) if ($row->COL_SRX_STRING) { $qso['srx_string'] = $row->COL_SRX_STRING; } - if ($row->COL_GRIDSQUARE) { + if ($row->COL_VUCC_GRIDS) { + $qso['gridsquare'] = $row->COL_VUCC_GRIDS; + } elseif ($row->COL_GRIDSQUARE) { $qso['gridsquare'] = $row->COL_GRIDSQUARE; } if ($row->COL_QTH) { @@ -1585,13 +1587,28 @@ function recent_qsos($public_slug = null, $limit = 10) $qso['name'] = $row->COL_NAME; } + // Prefer grid-derived coordinates for plotting, including VUCC multi-grid line/corner records. + $plot_latlng = false; + if (!empty($row->COL_VUCC_GRIDS)) { + $plot_latlng = $this->qralatlng($row->COL_VUCC_GRIDS); + } elseif (!empty($row->COL_GRIDSQUARE)) { + $plot_latlng = $this->qralatlng($row->COL_GRIDSQUARE); + } + + if (is_array($plot_latlng) && count($plot_latlng) === 2) { + $qso['lat'] = $plot_latlng[0]; + $qso['long'] = $plot_latlng[1]; + } + $dxcc = $this->logbook_model->check_dxcc_table(strtoupper(trim(strtoupper($row->COL_CALL))), $row->COL_TIME_ON); if (empty($dxcc[0])) { $dxcc_id = null; } else { $qso['country'] = $dxcc[1]; - $qso['lat'] = $dxcc[4]; - $qso['long'] = $dxcc[5]; + if (!isset($qso['lat']) || !isset($qso['long'])) { + $qso['lat'] = $dxcc[4]; + $qso['long'] = $dxcc[5]; + } } $qsos[] = $qso; diff --git a/application/controllers/Award.php b/application/controllers/Award.php index a10c4aec0..0dcd89af0 100644 --- a/application/controllers/Award.php +++ b/application/controllers/Award.php @@ -18,8 +18,20 @@ function __construct() public function index() { $this->load->model('awards_model'); + $this->load->model('user_options_model'); + $this->load->library('plugin_manager'); $data['user_awards'] = $this->awards_model->get_user_awards(); + $data['plugin_award_entries'] = $this->plugin_manager->get_award_menu_entries(); + + $plugin_award_visibility = array(); + $visibility_query = $this->user_options_model->get_options('plugin_awards_menu'); + foreach ($visibility_query->result() as $option_row) { + if ($option_row->option_key === 'show') { + $plugin_award_visibility[$option_row->option_name] = (string)$option_row->option_value === '1'; + } + } + $data['plugin_award_visibility'] = $plugin_award_visibility; // Render Page $data['page_title'] = "Award Settings"; @@ -58,4 +70,37 @@ public function deactivateall() { echo json_encode(array('message' => 'OK')); return; } + + public function savePluginAward() { + $plugin_slug = strtolower(trim((string)$this->security->xss_clean($this->input->post('plugin_slug')))); + $show_in_menu = trim((string)$this->security->xss_clean($this->input->post('show_in_menu'))); + + if (!preg_match('/^[a-z0-9_-]+$/', $plugin_slug)) { + header('Content-Type: application/json'); + echo json_encode(array('message' => 'Invalid plugin slug')); + return; + } + + $this->load->library('plugin_manager'); + $entries = $this->plugin_manager->get_award_menu_entries(); + $known_slugs = array(); + foreach ($entries as $entry) { + $known_slugs[] = $entry['slug']; + } + + if (!in_array($plugin_slug, $known_slugs, true)) { + header('Content-Type: application/json'); + echo json_encode(array('message' => 'Plugin award entry not found')); + return; + } + + $this->load->model('user_options_model'); + $this->user_options_model->set_option('plugin_awards_menu', $plugin_slug, array( + 'show' => $show_in_menu === '1' ? '1' : '0', + )); + + header('Content-Type: application/json'); + echo json_encode(array('message' => 'OK')); + return; + } } diff --git a/application/controllers/Awards.php b/application/controllers/Awards.php index ce3f61514..293790327 100644 --- a/application/controllers/Awards.php +++ b/application/controllers/Awards.php @@ -108,6 +108,7 @@ public function dxcc() $data['worked_bands'] = $this->bands->get_worked_bands('dxcc'); // Used in the view for band select $data['modes'] = $this->modes->active(); // Used in the view for mode select + $data['worked_years'] = $this->dxcc->get_worked_years(); // Used in the view for year select if ($this->input->post('band') != NULL) { // Band is not set when page first loads. if ($this->input->post('band') == 'All') { // Did the user specify a band? If not, use all bands @@ -138,6 +139,7 @@ public function dxcc() $postdata['Antarctica'] = $this->security->xss_clean($this->input->post('Antarctica')); $postdata['band'] = $this->security->xss_clean($this->input->post('band')); $postdata['mode'] = $this->security->xss_clean($this->input->post('mode')); + $postdata['year'] = $this->security->xss_clean($this->input->post('year')); } else { // Setting default values at first load of page $postdata['qsl'] = 1; $postdata['lotw'] = 1; @@ -155,6 +157,7 @@ public function dxcc() $postdata['Antarctica'] = 1; $postdata['band'] = 'All'; $postdata['mode'] = 'All'; + $postdata['year'] = 'All'; } $dxcclist = $this->dxcc->fetchdxcc($postdata); @@ -813,51 +816,51 @@ public function gmdxsummer() $this->load->model('gmdxsummer_model'); // Get Week 1 - $data['week1_6m_cw'] = $this->gmdxsummer_model->get_week('2024-05-26 18:00:00', '6m', 'CW'); - $data['week1_6m_ssb'] = $this->gmdxsummer_model->get_week_voice('2024-05-26 18:00:00', '6m'); - $data['week1_6m_digital'] = $this->gmdxsummer_model->get_week_digital('2024-05-26 18:00:00', '6m'); - $data['week1_6m_combined'] = $this->gmdxsummer_model->get_week_combined('2024-05-26 18:00:00', '6m'); + $data['week1_6m_cw'] = $this->gmdxsummer_model->get_week('2026-05-24 18:00:00', '6m', 'CW'); + $data['week1_6m_ssb'] = $this->gmdxsummer_model->get_week_voice('2026-05-24 18:00:00', '6m'); + $data['week1_6m_digital'] = $this->gmdxsummer_model->get_week_digital('2026-05-24 18:00:00', '6m'); + $data['week1_6m_combined'] = $this->gmdxsummer_model->get_week_combined('2026-05-24 18:00:00', '6m'); - $data['week1_4m_cw'] = $this->gmdxsummer_model->get_week('2024-05-26 18:00:00', '4m', 'CW'); - $data['week1_4m_ssb'] = $this->gmdxsummer_model->get_week_voice('2024-05-26 18:00:00', '4m'); - $data['week1_4m_digital'] = $this->gmdxsummer_model->get_week_digital('2024-05-26 18:00:00', '4m'); - $data['week1_4m_combined'] = $this->gmdxsummer_model->get_week_combined('2024-05-26 18:00:00', '4m'); + $data['week1_4m_cw'] = $this->gmdxsummer_model->get_week('2026-05-24 18:00:00', '4m', 'CW'); + $data['week1_4m_ssb'] = $this->gmdxsummer_model->get_week_voice('2026-05-24 18:00:00', '4m'); + $data['week1_4m_digital'] = $this->gmdxsummer_model->get_week_digital('2026-05-24 18:00:00', '4m'); + $data['week1_4m_combined'] = $this->gmdxsummer_model->get_week_combined('2026-05-24 18:00:00', '4m'); // Get Week 2 - $data['week2_6m_cw'] = $this->gmdxsummer_model->get_week('2024-06-09 18:00:00', '6m', 'CW'); - $data['week2_6m_ssb'] = $this->gmdxsummer_model->get_week_voice('2024-06-09 18:00:00', '6m'); - $data['week2_6m_digital'] = $this->gmdxsummer_model->get_week_digital('2024-06-09 18:00:00', '6m'); - $data['week2_6m_combined'] = $this->gmdxsummer_model->get_week_combined('2024-06-09 18:00:00', '6m'); + $data['week2_6m_cw'] = $this->gmdxsummer_model->get_week('2026-06-07 18:00:00', '6m', 'CW'); + $data['week2_6m_ssb'] = $this->gmdxsummer_model->get_week_voice('2026-06-07 18:00:00', '6m'); + $data['week2_6m_digital'] = $this->gmdxsummer_model->get_week_digital('2026-06-07 18:00:00', '6m'); + $data['week2_6m_combined'] = $this->gmdxsummer_model->get_week_combined('2026-06-07 18:00:00', '6m'); - $data['week2_4m_cw'] = $this->gmdxsummer_model->get_week('2024-06-09 18:00:00', '4m', 'CW'); - $data['week2_4m_ssb'] = $this->gmdxsummer_model->get_week_voice('2024-06-09 18:00:00', '4m'); - $data['week2_4m_digital'] = $this->gmdxsummer_model->get_week_digital('2024-06-09 18:00:00', '4m'); - $data['week2_4m_combined'] = $this->gmdxsummer_model->get_week_combined('2024-06-09 18:00:00', '4m'); + $data['week2_4m_cw'] = $this->gmdxsummer_model->get_week('2026-06-07 18:00:00', '4m', 'CW'); + $data['week2_4m_ssb'] = $this->gmdxsummer_model->get_week_voice('2026-06-07 18:00:00', '4m'); + $data['week2_4m_digital'] = $this->gmdxsummer_model->get_week_digital('2026-06-07 18:00:00', '4m'); + $data['week2_4m_combined'] = $this->gmdxsummer_model->get_week_combined('2026-06-07 18:00:00', '4m'); // Get Week 3 - $data['week3_6m_cw'] = $this->gmdxsummer_model->get_week('2024-06-23 18:00:00', '6m', 'CW'); - $data['week3_6m_ssb'] = $this->gmdxsummer_model->get_week_voice('2024-06-23 18:00:00', '6m'); - $data['week3_6m_digital'] = $this->gmdxsummer_model->get_week_digital('2024-06-23 18:00:00', '6m'); - $data['week3_6m_combined'] = $this->gmdxsummer_model->get_week_combined('2024-06-23 18:00:00', '6m'); + $data['week3_6m_cw'] = $this->gmdxsummer_model->get_week('2026-06-21 18:00:00', '6m', 'CW'); + $data['week3_6m_ssb'] = $this->gmdxsummer_model->get_week_voice('2026-06-21 18:00:00', '6m'); + $data['week3_6m_digital'] = $this->gmdxsummer_model->get_week_digital('2026-06-21 18:00:00', '6m'); + $data['week3_6m_combined'] = $this->gmdxsummer_model->get_week_combined('2026-06-21 18:00:00', '6m'); - $data['week3_4m_cw'] = $this->gmdxsummer_model->get_week('2024-06-23 18:00:00', '4m', 'CW'); - $data['week3_4m_ssb'] = $this->gmdxsummer_model->get_week_voice('2024-06-23 18:00:00', '4m'); - $data['week3_4m_digital'] = $this->gmdxsummer_model->get_week_digital('2024-06-23 18:00:00', '4m'); - $data['week3_4m_combined'] = $this->gmdxsummer_model->get_week_combined('2024-06-23 18:00:00', '4m'); + $data['week3_4m_cw'] = $this->gmdxsummer_model->get_week('2026-06-21 18:00:00', '4m', 'CW'); + $data['week3_4m_ssb'] = $this->gmdxsummer_model->get_week_voice('2026-06-21 18:00:00', '4m'); + $data['week3_4m_digital'] = $this->gmdxsummer_model->get_week_digital('2026-06-21 18:00:00', '4m'); + $data['week3_4m_combined'] = $this->gmdxsummer_model->get_week_combined('2026-06-21 18:00:00', '4m'); // Get Week 4 - $data['week4_6m_cw'] = $this->gmdxsummer_model->get_week('2024-07-01 18:00:00', '6m', 'CW'); - $data['week4_6m_ssb'] = $this->gmdxsummer_model->get_week_voice('2024-07-01 18:00:00', '6m'); - $data['week4_6m_digital'] = $this->gmdxsummer_model->get_week_digital('2024-07-01 18:00:00', '6m'); - $data['week4_6m_combined'] = $this->gmdxsummer_model->get_week_combined('2024-07-01 18:00:00', '6m'); - - $data['week4_4m_cw'] = $this->gmdxsummer_model->get_week('2024-07-01 18:00:00', '4m', 'CW'); - $data['week4_4m_ssb'] = $this->gmdxsummer_model->get_week_voice('2024-07-01 18:00:00', '4m'); - $data['week4_4m_digital'] = $this->gmdxsummer_model->get_week_digital('2024-07-01 18:00:00', '4m'); - $data['week4_4m_combined'] = $this->gmdxsummer_model->get_week_combined('2024-07-01 18:00:00', '4m'); + $data['week4_6m_cw'] = $this->gmdxsummer_model->get_week('2026-07-05 23:59:59', '6m', 'CW'); + $data['week4_6m_ssb'] = $this->gmdxsummer_model->get_week_voice('2026-07-05 23:59:59', '6m'); + $data['week4_6m_digital'] = $this->gmdxsummer_model->get_week_digital('2026-07-05 23:59:59', '6m'); + $data['week4_6m_combined'] = $this->gmdxsummer_model->get_week_combined('2026-07-05 23:59:59', '6m'); + + $data['week4_4m_cw'] = $this->gmdxsummer_model->get_week('2026-07-05 23:59:59', '4m', 'CW'); + $data['week4_4m_ssb'] = $this->gmdxsummer_model->get_week_voice('2026-07-05 23:59:59', '4m'); + $data['week4_4m_digital'] = $this->gmdxsummer_model->get_week_digital('2026-07-05 23:59:59', '4m'); + $data['week4_4m_combined'] = $this->gmdxsummer_model->get_week_combined('2026-07-05 23:59:59', '4m'); // Render page diff --git a/application/controllers/Clublog.php b/application/controllers/Clublog.php index ade2b77f0..c6efc4b09 100644 --- a/application/controllers/Clublog.php +++ b/application/controllers/Clublog.php @@ -16,9 +16,15 @@ public function upload() { $this->load->model('clublog_model'); $users = $this->clublog_model->get_clublog_users(); + $uploaded_any_qsos = false; foreach ($users as $user) { - $this->uploadUser($user->user_id, $user->user_clublog_name, $user->user_clublog_password); + $uploaded_any_qsos = $this->uploadUser($user->user_id, $user->user_clublog_name, $user->user_clublog_password) || $uploaded_any_qsos; + } + + if (!$uploaded_any_qsos) { + echo 'Nothing awaiting upload to Clublog.' . "
"; + log_message('info', 'Nothing awaiting upload to Clublog.'); } } @@ -31,7 +37,7 @@ function uploadUser($userid, $username, $password) { if (!empty($clean_username) && !filter_var($clean_username, FILTER_VALIDATE_EMAIL)) { echo "Error: Clublog username must be a valid email address. Clublog no longer accepts callsigns as usernames for " . $clean_username . "
"; log_message('error', 'Clublog upload failed for user ID ' . $clean_userid . ': invalid email format for username ' . $clean_username); - return; + return false; } $this->config->load('config'); @@ -48,6 +54,7 @@ function uploadUser($userid, $username, $password) { $station_profiles = $this->clublog_model->all_with_count($clean_userid); if($station_profiles->num_rows()){ + $uploaded_for_user = false; foreach ($station_profiles->result() as $station_row) { // Only process stations that have QSOs to upload @@ -73,10 +80,13 @@ function uploadUser($userid, $username, $password) { // Initialize the CURL request to Clublog's API endpoint $request = curl_init('https://clublog.org/putlogs.php'); - if($this->config->item('directory') != "") { - $filepath = $_SERVER['DOCUMENT_ROOT']."/".$this->config->item('directory')."/".$file_info['server_path']; - } else { - $filepath = $_SERVER['DOCUMENT_ROOT']."/".$file_info['server_path']; + $filepath = FCPATH . ltrim($file_info['server_path'], '/\\'); + if (!file_exists($filepath) && !empty($this->config->item('directory'))) { + $document_root = rtrim((string)($_SERVER['DOCUMENT_ROOT'] ?? ''), '/\\'); + $install_dir = trim((string)$this->config->item('directory'), '/\\'); + if ($document_root !== '') { + $filepath = $document_root . '/' . $install_dir . '/' . ltrim($file_info['server_path'], '/\\'); + } } if (function_exists('curl_file_create')) { // php 5.5+ @@ -112,6 +122,7 @@ function uploadUser($userid, $username, $password) { // If Clublog Accepts mark the QSOs if (preg_match('/\baccepted\b/', $response)) { echo "QSOs uploaded and Logbook QSOs marked as sent to Clublog"."
"; + $uploaded_for_user = true; $this->load->model('clublog_model'); $this->clublog_model->mark_qsos_sent($station_row->station_id); @@ -141,7 +152,11 @@ function uploadUser($userid, $username, $password) { log_message('info', 'Nothing awaiting upload to clublog for '.$station_row->station_callsign); } } + + return $uploaded_for_user; } + + return false; } } diff --git a/application/controllers/Dashboard.php b/application/controllers/Dashboard.php index cacf9194f..ea3ac53b4 100644 --- a/application/controllers/Dashboard.php +++ b/application/controllers/Dashboard.php @@ -11,6 +11,7 @@ public function __construct() $this->load->model('user_model'); $this->load->model('logbook_model'); $this->load->model('logbooks_model'); + $this->load->helper('dashboard'); } public function index() @@ -229,16 +230,23 @@ public function logbook_display_component() { return; } + $this->load->library('user_agent'); + // Get Logbook Locations $logbooks_locations_array = $this->logbooks_model->list_logbook_relationships($this->session->userdata('active_station_logbook')); - // Get the last 20 QSOs - $data['last_five_qsos'] = $this->logbook_model->get_last_qsos('20', $logbooks_locations_array); + // Show fewer rows on mobile to reduce dashboard noise. + $recent_qso_limit = $this->agent->is_mobile() ? '10' : '20'; + $data['last_five_qsos'] = $this->logbook_model->get_last_qsos($recent_qso_limit, $logbooks_locations_array); $this->load->view('components/dashboard_logbook_table', $data); } function radio_display_component() { + if ($this->user_model->validate_session() == 0) { + return; + } + $this->load->model('cat'); $data['radio_status'] = $this->cat->recent_status(); diff --git a/application/controllers/Lotw.php b/application/controllers/Lotw.php index a709c5e6c..2fa53bf0d 100644 --- a/application/controllers/Lotw.php +++ b/application/controllers/Lotw.php @@ -1157,7 +1157,15 @@ function lotw_satellite_map($satname) { "INSPR7" => "INSPIRE-SAT 7", "SONATE" => "SONATE-2", 'AO-123' => "ASRTU-1", + 'TEV2-1' => "TEVEL2-1", + 'TEV2-2' => "TEVEL2-2", 'TEV2-3' => "TEVEL2-3", + 'TEV2-4' => "TEVEL2-4", + 'TEV2-5' => "TEVEL2-5", + 'TEV2-6' => "TEVEL2-6", + 'TEV2-7' => "TEVEL2-7", + 'TEV2-8' => "TEVEL2-8", + 'TEV2-9' => "TEVEL2-9", ); return array_search(strtoupper($satname),$arr,true); diff --git a/application/controllers/Plugin_awards.php b/application/controllers/Plugin_awards.php new file mode 100644 index 000000000..b19bff5a0 --- /dev/null +++ b/application/controllers/Plugin_awards.php @@ -0,0 +1,41 @@ +load->model('user_model'); + if (!$this->user_model->authorize(2)) { + $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); + redirect('dashboard'); + } + + $this->load->library('plugin_manager'); + } + + public function view($slug = '') + { + $slug = strtolower(trim((string)$slug)); + $result = $this->plugin_manager->render_award_page($slug); + + if (!isset($result['ok']) || $result['ok'] !== true) { + $message = isset($result['message']) ? $result['message'] : 'Unable to load plugin award page.'; + $this->session->set_flashdata('notice', $message); + redirect('awards'); + return; + } + + $data['page_title'] = $result['page_title']; + $data['plugin_award_title'] = $result['page_title']; + $data['plugin_award_content'] = $result['content']; + $data['plugin_award_slug'] = $result['plugin_slug']; + + $this->load->view('interface_assets/header', $data); + $this->load->view('plugins/award_page', $data); + $this->load->view('interface_assets/footer'); + } +} diff --git a/application/controllers/Plugins.php b/application/controllers/Plugins.php new file mode 100644 index 000000000..6ceccb8d1 --- /dev/null +++ b/application/controllers/Plugins.php @@ -0,0 +1,171 @@ +load->model('user_model'); + if ($this->user_model->validate_session() == 0) { + redirect('user/login'); + } + + if ((int)$this->session->userdata('user_type') !== 99) { + $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); + redirect('dashboard'); + } + + $this->load->library('plugin_manager'); + } + + public function index() + { + $data['page_title'] = 'Plugin Manager'; + $data['plugins'] = $this->plugin_manager->list_plugins(); + $data['plugins_csrf_token'] = $this->get_plugins_csrf_token(); + $data['plugins_security_alert'] = (string)$this->session->flashdata(self::PLUGINS_SECURITY_ALERT_SESSION_KEY); + + $this->load->view('interface_assets/header', $data); + $this->load->view('plugins/index', $data); + $this->load->view('interface_assets/footer'); + } + + public function upload() + { + if (strtolower($this->input->method()) !== 'post') { + redirect('plugins'); + return; + } + + if (!$this->validate_plugins_csrf_token((string)$this->input->post('plugins_csrf_token', true))) { + $this->session->set_flashdata('notice', 'Security validation failed. Please retry from the Plugin Manager page.'); + redirect('plugins'); + return; + } + + $upload_dir = FCPATH . 'uploads' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR; + if (!is_dir($upload_dir) && !@mkdir($upload_dir, 0755, true)) { + $this->session->set_flashdata('notice', 'Unable to create plugin upload directory.'); + redirect('plugins'); + return; + } + + $config = array( + 'upload_path' => $upload_dir, + 'allowed_types' => 'zip|ZIP', + 'max_size' => 20480, + 'encrypt_name' => true, + 'detect_mime' => true, + 'mod_mime_fix' => true, + ); + + $this->load->library('upload'); + $this->upload->initialize($config); + + if (!$this->upload->do_upload('plugin_zip')) { + $this->session->set_flashdata('notice', trim(strip_tags($this->upload->display_errors('', '')))); + redirect('plugins'); + return; + } + + $upload_data = $this->upload->data(); + $result = $this->plugin_manager->install_from_zip($upload_data['full_path']); + @unlink($upload_data['full_path']); + + if (isset($result['security_alert']) && trim((string)$result['security_alert']) !== '') { + $this->session->set_flashdata(self::PLUGINS_SECURITY_ALERT_SESSION_KEY, (string)$result['security_alert']); + } + + $this->session->set_flashdata('notice', $result['message']); + redirect('plugins'); + } + + public function enable() + { + if (strtolower($this->input->method()) !== 'post') { + redirect('plugins'); + return; + } + + if (!$this->validate_plugins_csrf_token((string)$this->input->post('plugins_csrf_token', true))) { + $this->session->set_flashdata('notice', 'Security validation failed. Please retry from the Plugin Manager page.'); + redirect('plugins'); + return; + } + + $slug = strtolower(trim((string)$this->input->post('plugin_slug', true))); + $result = $this->plugin_manager->set_enabled($slug, true); + $this->session->set_flashdata('notice', $result['message']); + + redirect('plugins'); + } + + public function disable() + { + if (strtolower($this->input->method()) !== 'post') { + redirect('plugins'); + return; + } + + if (!$this->validate_plugins_csrf_token((string)$this->input->post('plugins_csrf_token', true))) { + $this->session->set_flashdata('notice', 'Security validation failed. Please retry from the Plugin Manager page.'); + redirect('plugins'); + return; + } + + $slug = strtolower(trim((string)$this->input->post('plugin_slug', true))); + $result = $this->plugin_manager->set_enabled($slug, false); + $this->session->set_flashdata('notice', $result['message']); + + redirect('plugins'); + } + + public function delete() + { + if (strtolower($this->input->method()) !== 'post') { + redirect('plugins'); + return; + } + + if (!$this->validate_plugins_csrf_token((string)$this->input->post('plugins_csrf_token', true))) { + $this->session->set_flashdata('notice', 'Security validation failed. Please retry from the Plugin Manager page.'); + redirect('plugins'); + return; + } + + $slug = strtolower(trim((string)$this->input->post('plugin_slug', true))); + $result = $this->plugin_manager->delete_plugin($slug); + $this->session->set_flashdata('notice', $result['message']); + + redirect('plugins'); + } + + private function get_plugins_csrf_token() + { + $token = (string)$this->session->userdata(self::PLUGINS_CSRF_SESSION_KEY); + + if ($token === '') { + $token = bin2hex(random_bytes(32)); + $this->session->set_userdata(self::PLUGINS_CSRF_SESSION_KEY, $token); + } + + return $token; + } + + private function validate_plugins_csrf_token($posted_token) + { + $session_token = (string)$this->session->userdata(self::PLUGINS_CSRF_SESSION_KEY); + + if ($session_token === '' || $posted_token === '') { + return false; + } + + return hash_equals($session_token, $posted_token); + } +} diff --git a/application/controllers/Qso.php b/application/controllers/Qso.php index b34b21ea7..984310d93 100755 --- a/application/controllers/Qso.php +++ b/application/controllers/Qso.php @@ -44,6 +44,15 @@ public function index() $data['bands'] = $this->bands->get_user_bands_for_qso_entry(); $data['user_default_band'] = $this->session->userdata('user_default_band'); $data['sat_active'] = array_search("SAT", $this->bands->get_user_bands(), true); + + $remote_operation_option = $this->user_options_model->get_options( + 'remote_operation', + array('option_name' => 'enabled', 'option_key' => 'value'), + $this->session->userdata('user_id') + )->row(); + $data['isRemoteOperationEnabled'] = isset($remote_operation_option->option_value) + ? ((string)$remote_operation_option->option_value === 'true' || (string)$remote_operation_option->option_value === '1') + : (bool)$this->session->userdata('isRemoteOperationEnabled'); // Set user's preferred date format if($this->session->userdata('user_date_format')) { @@ -304,6 +313,62 @@ function winkeysettings() { } } + function remoteoperationsettings() { + $this->load->view('qso/components/remoteoperationsettings'); + } + + public function remoteoperationsecret_json() { + header('Content-Type: application/json; charset=utf-8'); + + try { + $this->load->model('user_options_model'); + $this->load->library('encryption'); + + $row = $this->user_options_model->get_options( + 'remote_operation', + array('option_name' => 'secret', 'option_key' => 'link_password') + )->row(); + + $encrypted = isset($row->option_value) ? (string)$row->option_value : ''; + $plain = ''; + if ($encrypted !== '') { + $decrypted = $this->encryption->decrypt($encrypted); + $plain = ($decrypted !== false && $decrypted !== null) ? (string)$decrypted : ''; + } + + echo json_encode(array('status' => 'ok', 'link_password' => $plain)); + } catch (Exception $e) { + echo json_encode(array('status' => 'error', 'message' => $e->getMessage())); + } + } + + public function remoteoperationsecret_save() { + header('Content-Type: application/json; charset=utf-8'); + + try { + $this->load->model('user_options_model'); + $this->load->library('encryption'); + + $link_password = trim((string)$this->security->xss_clean($this->input->post('link_password', true))); + + if ($link_password !== '' && strlen($link_password) < 16) { + echo json_encode(array('status' => 'error', 'message' => 'Link password must be at least 16 characters')); + return; + } + + if ($link_password === '') { + $this->user_options_model->set_option('remote_operation', 'secret', array('link_password' => '')); + } else { + $encrypted = $this->encryption->encrypt($link_password); + $this->user_options_model->set_option('remote_operation', 'secret', array('link_password' => $encrypted)); + } + + echo json_encode(array('status' => 'ok')); + } catch (Exception $e) { + echo json_encode(array('status' => 'error', 'message' => $e->getMessage())); + } + } + public function cwmacrosave(){ try { // Get the data from the form with proper sanitization @@ -433,6 +498,115 @@ public function cwmacros_json() { } } + public function winkeyrelaytoken_json() { + header('Content-Type: application/json; charset=utf-8'); + + try { + $this->load->model('user_options_model'); + $result = $this->user_options_model->get_options('winkey_websocket_relay', array('option_name' => 'relay', 'option_key' => 'token'))->result(); + $token = isset($result[0]->option_value) ? (string)$result[0]->option_value : ''; + + echo json_encode(array('status' => 'ok', 'token' => $token)); + } catch (Exception $e) { + echo json_encode(array('status' => 'error', 'message' => $e->getMessage())); + } + } + + public function winkeyrelaytoken_save() { + header('Content-Type: application/json; charset=utf-8'); + + try { + $token = trim((string)$this->security->xss_clean($this->input->post('token', true))); + + if ($token !== '' && strlen($token) < 8) { + echo json_encode(array('status' => 'error', 'message' => 'Relay token must be at least 8 characters')); + return; + } + + $this->load->model('user_options_model'); + $this->user_options_model->set_option('winkey_websocket_relay', 'relay', array('token' => $token)); + + echo json_encode(array('status' => 'ok')); + } catch (Exception $e) { + echo json_encode(array('status' => 'error', 'message' => $e->getMessage())); + } + } + + public function winkeyrelaysettings_json() { + header('Content-Type: application/json; charset=utf-8'); + + try { + $this->load->model('user_options_model'); + $rows = $this->user_options_model->get_options('winkey_websocket_relay', array('option_name' => 'relay'))->result(); + + $settings = array( + 'enabled' => false, + 'url' => 'wss://relay.cloudlog.org/', + 'room' => 'cw_room', + 'token' => '', + ); + + foreach ($rows as $row) { + if ($row->option_key === 'enabled') { + $settings['enabled'] = ((string)$row->option_value === '1'); + } elseif ($row->option_key === 'url' && (string)$row->option_value !== '') { + $settings['url'] = (string)$row->option_value; + } elseif ($row->option_key === 'room' && (string)$row->option_value !== '') { + $settings['room'] = (string)$row->option_value; + } elseif ($row->option_key === 'token') { + $settings['token'] = (string)$row->option_value; + } + } + + echo json_encode(array('status' => 'ok', 'settings' => $settings)); + } catch (Exception $e) { + echo json_encode(array('status' => 'error', 'message' => $e->getMessage())); + } + } + + public function winkeyrelaysettings_save() { + header('Content-Type: application/json; charset=utf-8'); + + try { + $enabled = $this->input->post('enabled', true) === '1'; + $url = trim((string)$this->security->xss_clean($this->input->post('url', true))); + $room = trim((string)$this->security->xss_clean($this->input->post('room', true))); + $token = trim((string)$this->security->xss_clean($this->input->post('token', true))); + + if ($url === '') { + $url = 'wss://relay.cloudlog.org/'; + } + + if ($room === '') { + $room = 'cw_room'; + } + + if ($enabled) { + if (!preg_match('/^wss?:\/\//', $url)) { + echo json_encode(array('status' => 'error', 'message' => 'Relay URL must start with ws:// or wss://')); + return; + } + + if (strlen($token) < 8) { + echo json_encode(array('status' => 'error', 'message' => 'Relay token must be at least 8 characters')); + return; + } + } + + $this->load->model('user_options_model'); + $this->user_options_model->set_option('winkey_websocket_relay', 'relay', array( + 'enabled' => $enabled ? '1' : '0', + 'url' => $url, + 'room' => $room, + 'token' => $token, + )); + + echo json_encode(array('status' => 'ok')); + } catch (Exception $e) { + echo json_encode(array('status' => 'error', 'message' => $e->getMessage())); + } + } + public function edit_ajax() { $this->load->model('logbook_model'); diff --git a/application/controllers/Radio.php b/application/controllers/Radio.php index 04df37c69..86abbc6d9 100755 --- a/application/controllers/Radio.php +++ b/application/controllers/Radio.php @@ -79,6 +79,10 @@ function status() { } function json($id) { + $this->output->set_header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + $this->output->set_header('Cache-Control: post-check=0, pre-check=0', false); + $this->output->set_header('Pragma: no-cache'); + $this->output->set_header('Expires: Sat, 01 Jan 2000 00:00:00 GMT'); $this->load->model('user_model'); diff --git a/application/controllers/User.php b/application/controllers/User.php index 409698e10..20b148816 100644 --- a/application/controllers/User.php +++ b/application/controllers/User.php @@ -187,6 +187,9 @@ function add() $data['user_quicklog_enter'] = $this->input->post('user_quicklog_enter'); $data['user_hamsat_key'] = $this->input->post('user_hamsat_key'); $data['user_hamsat_workable_only'] = $this->input->post('user_hamsat_workable_only'); + $data['user_winkey'] = $this->input->post('user_winkey'); + $data['user_winkey_websocket'] = $this->input->post('user_winkey_websocket'); + $data['user_remote_operation'] = $this->input->post('user_remote_operation'); $data['language'] = $this->input->post('language'); $this->load->view('user/edit', $data); } else { @@ -231,7 +234,10 @@ function add() $this->input->post('user_hamsat_workable_only'), $this->input->post('user_callbook_type'), $this->input->post('user_callbook_username'), - $this->input->post('user_callbook_password') + $this->input->post('user_callbook_password'), + $this->input->post('user_winkey'), + $this->input->post('user_winkey_websocket'), + $this->input->post('user_remote_operation') )) { // Check for errors case EUSERNAMEEXISTS: @@ -615,6 +621,19 @@ function edit() } else { $data['user_winkey_websocket'] = $q->winkey_websocket; } + + if ($this->input->post('user_remote_operation')) { + $data['user_remote_operation'] = $this->input->post('user_remote_operation', true); + } else { + $remote_operation_option = $this->user_options_model->get_options( + 'remote_operation', + array('option_name' => 'enabled', 'option_key' => 'value'), + $this->uri->segment(3) + )->row(); + $data['user_remote_operation'] = isset($remote_operation_option->option_value) + ? (((string)$remote_operation_option->option_value === 'true' || (string)$remote_operation_option->option_value === '1') ? 1 : 0) + : (isset($q->remote_operation) ? $q->remote_operation : 0); + } $this->load->model('user_options_model'); $callbook_type_object = $this->user_options_model->get_options('callbook')->result(); @@ -827,6 +846,9 @@ function edit() if (!isset($post_data['user_winkey_websocket'])) { $post_data['user_winkey_websocket'] = '0'; } + if (!isset($post_data['user_remote_operation'])) { + $post_data['user_remote_operation'] = '0'; + } switch ($this->user_model->edit($post_data)) { // Check for errors case EUSERNAMEEXISTS: @@ -963,6 +985,12 @@ function edit() $this->session->set_userdata('user_show_qsl_cards', false); } + if (isset($post_data['user_remote_operation']) && (string)$post_data['user_remote_operation'] === '1') { + $this->user_options_model->set_option('remote_operation', 'enabled', array('value' => 'true')); + } else { + $this->user_options_model->set_option('remote_operation', 'enabled', array('value' => 'false')); + } + // [QSO Form] Save field visibility preferences $qso_field_keys = ['rst', 'name', 'qth', 'locator', 'comment', 'station_tab', 'freq_tx', 'freq_rx', 'band_rx', 'transmit_power', 'operator_callsign', @@ -991,7 +1019,13 @@ function edit() $this->user_options_model->del_option('map_custom', 'gridsquare'); } - $this->session->set_flashdata('success', lang('account_user') . ' ' . $this->input->post('user_name', true) . ' ' . lang('account_word_edited')); + $remote_operation_status_message = ((string)$post_data['user_remote_operation'] === '1') + ? 'Remote Operation enabled.' + : 'Remote Operation disabled.'; + $this->session->set_flashdata('success', lang('account_user') . ' ' . $this->input->post('user_name', true) . ' ' . lang('account_word_edited') . ' ' . $remote_operation_status_message); + if ($this->session->userdata('user_id') == $this->input->post('id', true)) { + $this->user_model->update_session($this->input->post('id', true)); + } redirect('user/edit/' . $this->uri->segment(3)); } else { $this->session->set_flashdata('success', lang('account_user') . ' ' . $this->input->post('user_name', true) . ' ' . lang('account_word_edited')); diff --git a/application/helpers/dashboard_helper.php b/application/helpers/dashboard_helper.php new file mode 100644 index 000000000..1d6aff6c7 --- /dev/null +++ b/application/helpers/dashboard_helper.php @@ -0,0 +1,167 @@ +' . $ctx->lang->line('gen_hamradio_mode') . ''; + break; + case 'RSTS': + echo '' . $ctx->lang->line('gen_hamradio_rsts') . ''; + break; + case 'RSTR': + echo '' . $ctx->lang->line('gen_hamradio_rstr') . ''; + break; + case 'Country': + echo '' . $ctx->lang->line('general_word_country') . ''; + break; + case 'IOTA': + echo '' . $ctx->lang->line('gen_hamradio_iota') . ''; + break; + case 'SOTA': + echo '' . $ctx->lang->line('gen_hamradio_sota') . ''; + break; + case 'WWFF': + echo '' . $ctx->lang->line('gen_hamradio_wwff') . ''; + break; + case 'POTA': + echo '' . $ctx->lang->line('gen_hamradio_pota') . ''; + break; + case 'State': + echo '' . $ctx->lang->line('gen_hamradio_state') . ''; + break; + case 'Grid': + echo '' . $ctx->lang->line('gen_hamradio_gridsquare') . ''; + break; + case 'Distance': + echo '' . $ctx->lang->line('gen_hamradio_distance') . ''; + break; + case 'Band': + echo '' . $ctx->lang->line('gen_hamradio_band') . ''; + break; + case 'Frequency': + echo '' . $ctx->lang->line('gen_hamradio_frequency') . ''; + break; + case 'Operator': + echo '' . $ctx->lang->line('gen_hamradio_operator') . ''; + break; + case 'Name': + echo '' . $ctx->lang->line('general_word_name') . ''; + break; + case 'Flag': + echo ' '; + break; + } + } +} + +if (!function_exists('echo_table_col')) { + function echo_table_col($row, $name) + { + $ci = &get_instance(); + switch ($name) { + case 'Mode': + echo ''; + echo $row->COL_SUBMODE == null ? $row->COL_MODE : $row->COL_SUBMODE . ''; + break; + case 'RSTS': + echo '' . $row->COL_RST_SENT; + if ($row->COL_STX) { + echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">'; + printf("%03d", $row->COL_STX); + echo ''; + } + if ($row->COL_STX_STRING) { + echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">' . $row->COL_STX_STRING . ''; + } + echo ''; + break; + case 'RSTR': + echo '' . $row->COL_RST_RCVD; + if ($row->COL_SRX) { + echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">'; + printf("%03d", $row->COL_SRX); + echo ''; + } + if ($row->COL_SRX_STRING) { + echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">' . $row->COL_SRX_STRING . ''; + } + echo ''; + break; + case 'Country': + echo '' . ucwords(strtolower(($row->COL_COUNTRY))); + if ($row->end != NULL) echo ' ' . $ci->lang->line('gen_hamradio_deleted_dxcc') . '' . ''; + break; + case 'IOTA': + echo '' . ($row->COL_IOTA) . ''; + break; + case 'SOTA': + echo '' . ($row->COL_SOTA_REF) . ''; + break; + case 'WWFF': + echo '' . ($row->COL_WWFF_REF) . ''; + break; + case 'POTA': + echo '' . ($row->COL_POTA_REF) . ''; + break; + case 'Grid': + echo ''; + echoQrbCalcLink($row->station_gridsquare, $row->COL_VUCC_GRIDS, $row->COL_GRIDSQUARE); + echo ''; + break; + case 'Distance': + echo '' . ($row->COL_DISTANCE ? $row->COL_DISTANCE . ' km' : '') . ''; + break; + case 'Band': + echo ''; + if ($row->COL_SAT_NAME != null) { + echo '' . $row->COL_SAT_NAME . ''; + } else { + echo strtolower($row->COL_BAND); + } + echo ''; + break; + case 'Frequency': + echo ''; + if ($row->COL_SAT_NAME != null) { + echo '' . $row->COL_SAT_NAME . ''; + } else { + if ($row->COL_FREQ != null) { + echo $ci->frequency->hz_to_mhz($row->COL_FREQ); + } else { + echo strtolower($row->COL_BAND); + } + } + echo ''; + break; + case 'State': + echo '' . ($row->COL_STATE) . ''; + break; + case 'Operator': + echo '' . ($row->COL_OPERATOR) . ''; + break; + case 'Name': + echo '' . ($row->COL_NAME) . ''; + break; + case 'Flag': + $ci->load->library('DxccFlag'); + $flag = strtolower($ci->dxccflag->getISO($row->COL_DXCC)); + echo 'name))) . '">'; + break; + } + } +} + +if (!function_exists('echoQrbCalcLink')) { + function echoQrbCalcLink($mygrid, $grid, $vucc) + { + if (!empty($grid)) { + echo $grid . ' '; + } else if (!empty($vucc)) { + echo $vucc . ' '; + } + } +} \ No newline at end of file diff --git a/application/libraries/Cloudlog_hooks.php b/application/libraries/Cloudlog_hooks.php new file mode 100644 index 000000000..e8adbafc3 --- /dev/null +++ b/application/libraries/Cloudlog_hooks.php @@ -0,0 +1,200 @@ +CI = &get_instance(); + $this->CI->load->model('plugins_model'); + } + + public function apply_filters($hook_name, $payload, $context = array()) + { + $handlers = $this->get_handlers_for_hook($hook_name); + if (empty($handlers)) { + return $payload; + } + + $current = $payload; + foreach ($handlers as $handler) { + $plugin_instance = $this->load_plugin_instance($handler['plugin'], $handler['manifest']); + if (!$plugin_instance) { + continue; + } + + $method = $handler['method']; + if (!method_exists($plugin_instance, $method)) { + $this->disable_plugin_after_failure( + $handler['plugin']->plugin_slug, + 'Hook method not found [' . $hook_name . '] method=' . $method + ); + continue; + } + + try { + $returned = $plugin_instance->$method($current, $context); + if ($returned !== null) { + $current = $returned; + } + } catch (Throwable $e) { + log_message('error', 'Plugin filter failed [' . $hook_name . '] plugin=' . $handler['plugin']->plugin_slug . ' error=' . $e->getMessage()); + $this->disable_plugin_after_failure( + $handler['plugin']->plugin_slug, + 'Hook filter exception [' . $hook_name . '] ' . $e->getMessage() + ); + } + } + + return $current; + } + + public function do_action($hook_name, $payload = array(), $context = array()) + { + $handlers = $this->get_handlers_for_hook($hook_name); + if (empty($handlers)) { + return; + } + + foreach ($handlers as $handler) { + $plugin_instance = $this->load_plugin_instance($handler['plugin'], $handler['manifest']); + if (!$plugin_instance) { + continue; + } + + $method = $handler['method']; + if (!method_exists($plugin_instance, $method)) { + $this->disable_plugin_after_failure( + $handler['plugin']->plugin_slug, + 'Hook method not found [' . $hook_name . '] method=' . $method + ); + continue; + } + + try { + $plugin_instance->$method($payload, $context); + } catch (Throwable $e) { + log_message('error', 'Plugin action failed [' . $hook_name . '] plugin=' . $handler['plugin']->plugin_slug . ' error=' . $e->getMessage()); + $this->disable_plugin_after_failure( + $handler['plugin']->plugin_slug, + 'Hook action exception [' . $hook_name . '] ' . $e->getMessage() + ); + } + } + } + + private function get_handlers_for_hook($hook_name) + { + if (!$this->CI->plugins_model->table_exists()) { + return array(); + } + + $enabled_plugins = $this->CI->plugins_model->get_enabled(); + if (empty($enabled_plugins)) { + return array(); + } + + $handlers = array(); + + foreach ($enabled_plugins as $plugin) { + $manifest = json_decode((string)$plugin->plugin_manifest, true); + if (!is_array($manifest) || !isset($manifest['hooks']) || !is_array($manifest['hooks'])) { + continue; + } + + if (!isset($manifest['hooks'][$hook_name])) { + continue; + } + + $method = $manifest['hooks'][$hook_name]; + if (!is_string($method) || $method === '') { + continue; + } + + $handlers[] = array( + 'plugin' => $plugin, + 'manifest' => $manifest, + 'method' => $method, + ); + } + + return $handlers; + } + + private function load_plugin_instance($plugin, $manifest) + { + $slug = $plugin->plugin_slug; + if (isset($this->plugin_instances[$slug])) { + return $this->plugin_instances[$slug]; + } + + $entry_file = isset($manifest['entry']) ? trim((string)$manifest['entry']) : 'Plugin.php'; + $class_name = isset($manifest['class']) ? trim((string)$manifest['class']) : 'Plugin'; + + if (!preg_match('/^[A-Za-z0-9_\/.-]+$/', $entry_file)) { + log_message('error', 'Plugin entry path invalid for ' . $slug); + $this->disable_plugin_after_failure($slug, 'Invalid plugin entry path'); + return null; + } + + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $class_name)) { + log_message('error', 'Plugin class invalid for ' . $slug); + $this->disable_plugin_after_failure($slug, 'Invalid plugin class name'); + return null; + } + + $plugin_path = APPPATH . 'plugins' . DIRECTORY_SEPARATOR . $slug . DIRECTORY_SEPARATOR; + $entry_path = realpath($plugin_path . $entry_file); + $plugin_root = realpath($plugin_path); + + if ($plugin_root === false || $entry_path === false || strpos($entry_path, $plugin_root) !== 0) { + log_message('error', 'Plugin entry file missing or outside plugin root for ' . $slug); + $this->disable_plugin_after_failure($slug, 'Plugin entry file missing or outside plugin root'); + return null; + } + + try { + require_once $entry_path; + } catch (Throwable $e) { + log_message('error', 'Plugin entry include failed (' . $slug . '): ' . $e->getMessage()); + $this->disable_plugin_after_failure($slug, 'Plugin entry include failed: ' . $e->getMessage()); + return null; + } + + if (!class_exists($class_name)) { + log_message('error', 'Plugin class not found: ' . $class_name . ' (' . $slug . ')'); + $this->disable_plugin_after_failure($slug, 'Plugin class not found: ' . $class_name); + return null; + } + + try { + $instance = new $class_name($this->CI); + } catch (Throwable $e) { + log_message('error', 'Plugin construction failed (' . $slug . '): ' . $e->getMessage()); + $this->disable_plugin_after_failure($slug, 'Plugin construction failed: ' . $e->getMessage()); + return null; + } + + $this->plugin_instances[$slug] = $instance; + + return $instance; + } + + private function disable_plugin_after_failure($slug, $reason) + { + if (!preg_match('/^[a-z0-9_-]+$/', (string)$slug)) { + return; + } + + if ($this->CI->plugins_model->table_exists()) { + $this->CI->plugins_model->set_status($slug, 'disabled'); + } + + unset($this->plugin_instances[$slug]); + log_message('error', 'Plugin auto-disabled: ' . $slug . ' reason=' . $reason); + } +} diff --git a/application/libraries/Plugin_manager.php b/application/libraries/Plugin_manager.php new file mode 100644 index 000000000..321adde1f --- /dev/null +++ b/application/libraries/Plugin_manager.php @@ -0,0 +1,745 @@ +CI = &get_instance(); + $this->CI->load->model('plugins_model'); + } + + public function list_plugins() + { + $plugins = $this->CI->plugins_model->get_all(); + $result = array(); + + foreach ($plugins as $plugin) { + $manifest = json_decode((string)$plugin->plugin_manifest, true); + if (!is_array($manifest)) { + $manifest = array(); + } + + $plugin_dir = $this->get_plugin_root() . $plugin->plugin_slug . DIRECTORY_SEPARATOR; + $manifest_path = $plugin_dir . 'manifest.json'; + + if (is_file($manifest_path)) { + $disk_manifest = $this->read_manifest($manifest_path); + if ($disk_manifest) { + $manifest = $disk_manifest; + } + } + + $result[] = (object)array( + 'plugin_slug' => $plugin->plugin_slug, + 'plugin_name' => isset($manifest['name']) ? $manifest['name'] : $plugin->plugin_name, + 'plugin_version' => isset($manifest['version']) ? $manifest['version'] : $plugin->plugin_version, + 'plugin_description' => isset($manifest['description']) ? $manifest['description'] : $plugin->plugin_description, + 'plugin_status' => $plugin->plugin_status, + 'installed_at' => $plugin->installed_at, + 'updated_at' => $plugin->updated_at, + 'path_exists' => is_dir($plugin_dir), + ); + } + + return $result; + } + + public function install_from_zip($zip_file) + { + if (!$this->CI->plugins_model->table_exists()) { + return array('ok' => false, 'message' => 'Plugins table does not exist. Run migrations first.'); + } + + if (!class_exists('ZipArchive')) { + return array('ok' => false, 'message' => 'ZipArchive extension is not available on this server.'); + } + + $this->ensure_plugin_root(); + + $zip = new ZipArchive(); + $open_result = $zip->open($zip_file); + if ($open_result !== true) { + return array('ok' => false, 'message' => 'Could not open plugin zip file.'); + } + + $invalid_path = $this->find_invalid_archive_path($zip); + if ($invalid_path !== null) { + $zip->close(); + return array('ok' => false, 'message' => 'Plugin archive contains an invalid path: ' . $invalid_path); + } + + $temp_base = FCPATH . 'uploads' . DIRECTORY_SEPARATOR . 'plugins_tmp' . DIRECTORY_SEPARATOR; + if (!is_dir($temp_base) && !@mkdir($temp_base, 0755, true)) { + $zip->close(); + return array('ok' => false, 'message' => 'Unable to create plugin temp directory.'); + } + + $extract_dir = $temp_base . uniqid('plugin_', true) . DIRECTORY_SEPARATOR; + if (!@mkdir($extract_dir, 0755, true)) { + $zip->close(); + return array('ok' => false, 'message' => 'Unable to create plugin extraction directory.'); + } + + if (!$zip->extractTo($extract_dir)) { + $zip->close(); + $this->recursive_delete($extract_dir); + return array('ok' => false, 'message' => 'Failed to extract plugin archive.'); + } + $zip->close(); + + $plugin_root = $this->resolve_extracted_plugin_root($extract_dir); + if ($plugin_root === null) { + $this->recursive_delete($extract_dir); + return array('ok' => false, 'message' => 'manifest.json was not found in the uploaded plugin.'); + } + + $manifest_path = $plugin_root . 'manifest.json'; + $manifest = $this->read_manifest($manifest_path); + if (!$manifest) { + $this->recursive_delete($extract_dir); + return array('ok' => false, 'message' => 'manifest.json is invalid JSON.'); + } + + $award_menu_warning = $this->validate_award_menu_definition($plugin_root, $manifest); + + $read_only_policy_error = $this->validate_plugin_read_only_policy($plugin_root); + if ($read_only_policy_error !== null) { + $this->recursive_delete($extract_dir); + return array( + 'ok' => false, + 'message' => 'Plugin blocked by security policy.', + 'security_alert' => $read_only_policy_error, + ); + } + + $slug = $this->resolve_slug($manifest); + if ($slug === null) { + $this->recursive_delete($extract_dir); + return array('ok' => false, 'message' => 'manifest.json must include a valid slug (letters, numbers, _ or -).'); + } + + $dest_dir = $this->get_plugin_root() . $slug . DIRECTORY_SEPARATOR; + + $is_upgrade = false; + $previous_version = null; + $backup_dir = null; + + if (is_dir($dest_dir)) { + $is_upgrade = true; + + $existing_manifest_path = $dest_dir . 'manifest.json'; + if (is_file($existing_manifest_path)) { + $existing_manifest = $this->read_manifest($existing_manifest_path); + if (is_array($existing_manifest) && isset($existing_manifest['version'])) { + $previous_version = (string)$existing_manifest['version']; + } + } + + $backup_base = FCPATH . 'uploads' . DIRECTORY_SEPARATOR . 'plugins_backup' . DIRECTORY_SEPARATOR; + if (!is_dir($backup_base) && !@mkdir($backup_base, 0755, true)) { + $this->recursive_delete($extract_dir); + return array('ok' => false, 'message' => 'Unable to create plugin backup directory.'); + } + + $backup_dir = $backup_base . $slug . '_' . date('Ymd_His') . '_' . substr(sha1(uniqid('', true)), 0, 8) . DIRECTORY_SEPARATOR; + if (!@rename($dest_dir, $backup_dir)) { + $this->recursive_delete($extract_dir); + return array('ok' => false, 'message' => 'Unable to backup existing plugin before upgrade.'); + } + } + + if (!$this->recursive_copy($plugin_root, $dest_dir)) { + $this->recursive_delete($extract_dir); + if ($is_upgrade && $backup_dir !== null && is_dir($backup_dir)) { + @rename($backup_dir, $dest_dir); + } + return array('ok' => false, 'message' => 'Failed to copy plugin files into application/plugins.'); + } + + $this->recursive_delete($extract_dir); + + if (!$this->CI->plugins_model->upsert_plugin($slug, $manifest, 'disabled')) { + $this->recursive_delete($dest_dir); + if ($is_upgrade && $backup_dir !== null && is_dir($backup_dir)) { + @rename($backup_dir, $dest_dir); + } + return array('ok' => false, 'message' => 'Failed to persist plugin metadata.'); + } + + if ($is_upgrade && $backup_dir !== null && is_dir($backup_dir)) { + $this->recursive_delete($backup_dir); + } + + $plugin_name = isset($manifest['name']) ? $manifest['name'] : $slug; + $new_version = isset($manifest['version']) ? (string)$manifest['version'] : null; + + if ($is_upgrade) { + if ($previous_version !== null && $new_version !== null) { + $message = 'Plugin upgraded: ' . $plugin_name . ' (' . $previous_version . ' -> ' . $new_version . ')'; + } else { + $message = 'Plugin upgraded: ' . $plugin_name; + } + } else { + $message = 'Plugin installed: ' . $plugin_name; + } + + if ($award_menu_warning !== null) { + $message .= ' Warning: ' . $award_menu_warning; + } + + return array('ok' => true, 'message' => $message); + } + + public function set_enabled($slug, $enabled) + { + if (!preg_match('/^[a-z0-9_-]+$/', $slug)) { + return array('ok' => false, 'message' => 'Invalid plugin slug.'); + } + + $plugin = $this->CI->plugins_model->get_by_slug($slug); + if (!$plugin) { + return array('ok' => false, 'message' => 'Plugin not found.'); + } + + $plugin_dir = $this->get_plugin_root() . $slug . DIRECTORY_SEPARATOR; + if (!is_dir($plugin_dir)) { + return array('ok' => false, 'message' => 'Plugin files are missing on disk.'); + } + + $status = $enabled ? 'enabled' : 'disabled'; + if (!$this->CI->plugins_model->set_status($slug, $status)) { + return array('ok' => false, 'message' => 'Failed to update plugin status.'); + } + + return array('ok' => true, 'message' => 'Plugin ' . $status . ': ' . $slug); + } + + public function delete_plugin($slug) + { + if (!preg_match('/^[a-z0-9_-]+$/', $slug)) { + return array('ok' => false, 'message' => 'Invalid plugin slug.'); + } + + if (!$this->CI->plugins_model->table_exists()) { + return array('ok' => false, 'message' => 'Plugins table does not exist.'); + } + + $plugin = $this->CI->plugins_model->get_by_slug($slug); + if (!$plugin) { + return array('ok' => false, 'message' => 'Plugin not found.'); + } + + $plugin_dir = $this->get_plugin_root() . $slug . DIRECTORY_SEPARATOR; + if (is_dir($plugin_dir)) { + $this->recursive_delete($plugin_dir); + if (is_dir($plugin_dir)) { + return array('ok' => false, 'message' => 'Failed to remove plugin files from disk.'); + } + } + + if (!$this->CI->plugins_model->delete_by_slug($slug)) { + return array('ok' => false, 'message' => 'Failed to remove plugin metadata.'); + } + + return array('ok' => true, 'message' => 'Plugin deleted: ' . $slug); + } + + public function get_award_menu_entries() + { + if (!$this->CI->plugins_model->table_exists()) { + return array(); + } + + $enabled_plugins = $this->CI->plugins_model->get_enabled(); + $entries = array(); + + foreach ($enabled_plugins as $plugin) { + $manifest = $this->get_manifest_for_plugin($plugin); + if (!is_array($manifest) || !isset($manifest['award_menu']) || !is_array($manifest['award_menu'])) { + continue; + } + + $award_menu = $manifest['award_menu']; + $title = isset($award_menu['title']) ? trim((string)$award_menu['title']) : ''; + if ($title === '') { + continue; + } + + $route = isset($award_menu['route']) ? trim((string)$award_menu['route']) : 'plugin_awards/view/' . $plugin->plugin_slug; + $route = ltrim($route, '/'); + if (!preg_match('/^[A-Za-z0-9_\/-]+$/', $route)) { + continue; + } + + $icon = isset($award_menu['icon']) ? trim((string)$award_menu['icon']) : 'fas fa-award'; + if ($icon === '') { + $icon = 'fas fa-award'; + } + + $order = isset($award_menu['order']) ? (int)$award_menu['order'] : 100; + + $entries[] = array( + 'slug' => $plugin->plugin_slug, + 'title' => $title, + 'icon' => $icon, + 'route' => $route, + 'order' => $order, + ); + } + + usort($entries, static function ($a, $b) { + if ($a['order'] === $b['order']) { + return strcmp($a['title'], $b['title']); + } + return $a['order'] <=> $b['order']; + }); + + return $entries; + } + + public function render_award_page($slug) + { + if (!preg_match('/^[a-z0-9_-]+$/', $slug)) { + return array('ok' => false, 'message' => 'Invalid plugin slug.'); + } + + if (!$this->CI->plugins_model->table_exists()) { + return array('ok' => false, 'message' => 'Plugins table does not exist.'); + } + + $plugin = $this->CI->plugins_model->get_by_slug($slug); + if (!$plugin || $plugin->plugin_status !== 'enabled') { + return array('ok' => false, 'message' => 'Award plugin is not enabled.'); + } + + $plugin_root = $this->get_plugin_root() . $slug . DIRECTORY_SEPARATOR; + $read_only_policy_error = $this->validate_plugin_read_only_policy($plugin_root); + if ($read_only_policy_error !== null) { + $this->disable_plugin_after_failure($slug, $read_only_policy_error); + return array('ok' => false, 'message' => 'Plugin disabled due to read-only policy violation.'); + } + + $manifest = $this->get_manifest_for_plugin($plugin); + if (!is_array($manifest) || !isset($manifest['award_menu']) || !is_array($manifest['award_menu'])) { + return array('ok' => false, 'message' => 'Plugin does not declare an award menu entry.'); + } + + $award_menu = $manifest['award_menu']; + $method = isset($award_menu['method']) ? trim((string)$award_menu['method']) : 'renderAwardPage'; + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $method)) { + $this->disable_plugin_after_failure($slug, 'Invalid plugin award method name: ' . $method); + return array('ok' => false, 'message' => 'Invalid plugin award method.'); + } + + $instance = $this->instantiate_plugin($plugin, $manifest); + if (!$instance) { + $this->disable_plugin_after_failure($slug, 'Unable to load plugin class instance'); + return array('ok' => false, 'message' => 'Unable to load plugin class.'); + } + + if (!method_exists($instance, $method)) { + $this->disable_plugin_after_failure($slug, 'Plugin award method not found: ' . $method); + return array('ok' => false, 'message' => 'Plugin award method not found: ' . $method); + } + + try { + $result = $instance->$method(array( + 'slug' => $slug, + 'manifest' => $manifest, + 'user_id' => (int)$this->CI->session->userdata('user_id'), + )); + } catch (Throwable $e) { + log_message('error', 'Plugin award render failed (' . $slug . '): ' . $e->getMessage()); + $this->disable_plugin_after_failure($slug, 'Plugin award render exception: ' . $e->getMessage()); + return array('ok' => false, 'message' => 'Plugin award rendering failed.'); + } + + $page_title = isset($award_menu['title']) ? (string)$award_menu['title'] : $plugin->plugin_name; + $content = ''; + + if (is_string($result)) { + $content = $result; + } elseif (is_array($result)) { + if (isset($result['page_title'])) { + $page_title = (string)$result['page_title']; + } + if (isset($result['content'])) { + $content = (string)$result['content']; + } + } + + if (trim($content) === '') { + return array('ok' => false, 'message' => 'Plugin award page returned empty content.'); + } + + return array( + 'ok' => true, + 'page_title' => $page_title, + 'content' => $content, + 'plugin_slug' => $slug, + ); + } + + public function get_plugin_root() + { + return APPPATH . 'plugins' . DIRECTORY_SEPARATOR; + } + + private function get_manifest_for_plugin($plugin) + { + $manifest = json_decode((string)$plugin->plugin_manifest, true); + if (!is_array($manifest)) { + $manifest = array(); + } + + $plugin_dir = $this->get_plugin_root() . $plugin->plugin_slug . DIRECTORY_SEPARATOR; + $manifest_path = $plugin_dir . 'manifest.json'; + if (is_file($manifest_path)) { + $disk_manifest = $this->read_manifest($manifest_path); + if ($disk_manifest) { + $manifest = $disk_manifest; + } + } + + return $manifest; + } + + private function instantiate_plugin($plugin, $manifest) + { + $entry_file = isset($manifest['entry']) ? trim((string)$manifest['entry']) : 'Plugin.php'; + $class_name = isset($manifest['class']) ? trim((string)$manifest['class']) : 'Plugin'; + + if (!preg_match('/^[A-Za-z0-9_\/.-]+$/', $entry_file)) { + log_message('error', 'Plugin entry path invalid for ' . $plugin->plugin_slug); + return null; + } + + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $class_name)) { + log_message('error', 'Plugin class invalid for ' . $plugin->plugin_slug); + return null; + } + + $plugin_path = $this->get_plugin_root() . $plugin->plugin_slug . DIRECTORY_SEPARATOR; + $entry_path = realpath($plugin_path . $entry_file); + $plugin_root = realpath($plugin_path); + + if ($plugin_root === false || $entry_path === false || strpos($entry_path, $plugin_root) !== 0) { + log_message('error', 'Plugin entry file missing or outside plugin root for ' . $plugin->plugin_slug); + return null; + } + + try { + require_once $entry_path; + } catch (Throwable $e) { + log_message('error', 'Plugin entry include failed (' . $plugin->plugin_slug . '): ' . $e->getMessage()); + return null; + } + + if (!class_exists($class_name)) { + log_message('error', 'Plugin class not found: ' . $class_name . ' (' . $plugin->plugin_slug . ')'); + return null; + } + + try { + return new $class_name($this->CI); + } catch (Throwable $e) { + log_message('error', 'Plugin construction failed (' . $plugin->plugin_slug . '): ' . $e->getMessage()); + return null; + } + } + + private function validate_award_menu_definition($plugin_root, $manifest) + { + if (!isset($manifest['award_menu']) || !is_array($manifest['award_menu'])) { + return null; + } + + $award_menu = $manifest['award_menu']; + $method = isset($award_menu['method']) ? trim((string)$award_menu['method']) : 'renderAwardPage'; + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $method)) { + return 'award_menu.method is invalid and may prevent Awards dropdown rendering.'; + } + + $entry_file = isset($manifest['entry']) ? trim((string)$manifest['entry']) : 'Plugin.php'; + if (!preg_match('/^[A-Za-z0-9_\/.-]+$/', $entry_file)) { + return 'award_menu is declared but entry path is invalid.'; + } + + $entry_path = realpath($plugin_root . $entry_file); + $plugin_root_real = realpath($plugin_root); + if ($entry_path === false || $plugin_root_real === false || strpos($entry_path, $plugin_root_real) !== 0 || !is_file($entry_path)) { + return 'award_menu is declared but plugin entry file was not found.'; + } + + $entry_content = @file_get_contents($entry_path); + if ($entry_content === false) { + return 'award_menu is declared but plugin entry file could not be read.'; + } + + $method_regex = '/function\s+' . preg_quote($method, '/') . '\s*\(/i'; + if (!preg_match($method_regex, $entry_content)) { + return 'award_menu is declared but method ' . $method . ' was not found in ' . $entry_file . '.'; + } + + return null; + } + + private function ensure_plugin_root() + { + $plugin_root = $this->get_plugin_root(); + if (!is_dir($plugin_root)) { + @mkdir($plugin_root, 0755, true); + @file_put_contents($plugin_root . 'index.html', ''); + } + } + + private function find_invalid_archive_path(ZipArchive $zip) + { + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name === false) { + return 'unknown'; + } + + $normalized = str_replace('\\', '/', $name); + if (strpos($normalized, '../') !== false || strpos($normalized, '..\\') !== false) { + return $name; + } + + if (preg_match('/^[a-zA-Z]:\//', $normalized) || strpos($normalized, '/') === 0) { + return $name; + } + } + + return null; + } + + private function resolve_extracted_plugin_root($extract_dir) + { + if (is_file($extract_dir . 'manifest.json')) { + return $extract_dir; + } + + $entries = @scandir($extract_dir); + if (!is_array($entries)) { + return null; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $extract_dir . $entry; + if (is_dir($path) && is_file($path . DIRECTORY_SEPARATOR . 'manifest.json')) { + return $path . DIRECTORY_SEPARATOR; + } + } + + return null; + } + + private function read_manifest($manifest_path) + { + $content = @file_get_contents($manifest_path); + if ($content === false) { + return null; + } + + $manifest = json_decode($content, true); + if (!is_array($manifest)) { + return null; + } + + return $manifest; + } + + private function resolve_slug($manifest) + { + if (!isset($manifest['slug'])) { + return null; + } + + $slug = strtolower(trim((string)$manifest['slug'])); + if (!preg_match('/^[a-z0-9_-]+$/', $slug)) { + return null; + } + + return $slug; + } + + private function recursive_copy($src, $dst) + { + if (!is_dir($src)) { + return false; + } + + if (!is_dir($dst) && !@mkdir($dst, 0755, true)) { + return false; + } + + $items = @scandir($src); + if (!is_array($items)) { + return false; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $from = $src . DIRECTORY_SEPARATOR . $item; + $to = $dst . DIRECTORY_SEPARATOR . $item; + + if (is_dir($from)) { + if (!$this->recursive_copy($from, $to)) { + return false; + } + } else { + if (!@copy($from, $to)) { + return false; + } + } + } + + return true; + } + + private function recursive_delete($path) + { + if (!file_exists($path)) { + return; + } + + if (is_file($path) || is_link($path)) { + @unlink($path); + return; + } + + $items = @scandir($path); + if (is_array($items)) { + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $this->recursive_delete($path . DIRECTORY_SEPARATOR . $item); + } + } + + @rmdir($path); + } + + private function disable_plugin_after_failure($slug, $reason) + { + if (!preg_match('/^[a-z0-9_-]+$/', (string)$slug)) { + return; + } + + if ($this->CI->plugins_model->table_exists()) { + $this->CI->plugins_model->set_status($slug, 'disabled'); + } + + log_message('error', 'Plugin auto-disabled: ' . $slug . ' reason=' . $reason); + } + + private function validate_plugin_read_only_policy($plugin_root) + { + $plugin_root_real = realpath($plugin_root); + if ($plugin_root_real === false || !is_dir($plugin_root_real)) { + return 'Plugin files are missing on disk.'; + } + + $php_files = $this->collect_php_files($plugin_root_real); + if (empty($php_files)) { + return null; + } + + foreach ($php_files as $php_file) { + $contents = @file_get_contents($php_file); + if ($contents === false) { + return 'Plugin policy check failed: unable to read ' . basename($php_file) . '.'; + } + + $violation = $this->find_forbidden_plugin_operation($contents); + if ($violation !== null) { + $relative_path = ltrim(str_replace($plugin_root_real, '', $php_file), DIRECTORY_SEPARATOR); + return 'Forbidden operation "' . $violation . '" in ' . $relative_path . '. Plugins must be read-only and cannot execute system commands.'; + } + } + + return null; + } + + private function collect_php_files($root) + { + $files = array(); + $items = @scandir($root); + if (!is_array($items)) { + return $files; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $root . DIRECTORY_SEPARATOR . $item; + if (is_dir($path)) { + $files = array_merge($files, $this->collect_php_files($path)); + continue; + } + + if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'php') { + $files[] = $path; + } + } + + return $files; + } + + private function find_forbidden_plugin_operation($php_code) + { + $forbidden_functions = array( + 'file_put_contents', + 'fopen', + 'fwrite', + 'fputs', + 'mkdir', + 'rmdir', + 'rename', + 'unlink', + 'copy', + 'touch', + 'symlink', + 'link', + 'move_uploaded_file', + 'chmod', + 'chown', + 'chgrp', + 'ftruncate', + 'tempnam', + 'tmpfile', + 'system', + 'exec', + 'shell_exec', + 'passthru', + 'proc_open', + 'popen', + 'pcntl_exec', + 'assert', + 'eval', + ); + + foreach ($forbidden_functions as $function_name) { + if (preg_match('/\\b' . preg_quote($function_name, '/') . '\\s*\\(/i', $php_code)) { + return $function_name; + } + } + + return null; + } +} diff --git a/application/migrations/268_create_plugins_table.php b/application/migrations/268_create_plugins_table.php new file mode 100644 index 000000000..51894e467 --- /dev/null +++ b/application/migrations/268_create_plugins_table.php @@ -0,0 +1,69 @@ +db->table_exists('plugins')) { + $this->dbforge->add_field(array( + 'id' => array( + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => TRUE, + 'auto_increment' => TRUE, + ), + 'plugin_slug' => array( + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => FALSE, + ), + 'plugin_name' => array( + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => FALSE, + ), + 'plugin_version' => array( + 'type' => 'VARCHAR', + 'constraint' => 60, + 'null' => FALSE, + 'default' => '1.0.0', + ), + 'plugin_description' => array( + 'type' => 'TEXT', + 'null' => TRUE, + ), + 'plugin_status' => array( + 'type' => 'VARCHAR', + 'constraint' => 16, + 'null' => FALSE, + 'default' => 'disabled', + ), + 'plugin_manifest' => array( + 'type' => 'MEDIUMTEXT', + 'null' => TRUE, + ), + 'installed_at' => array( + 'type' => 'DATETIME', + 'null' => FALSE, + ), + 'updated_at' => array( + 'type' => 'DATETIME', + 'null' => FALSE, + ), + )); + + $this->dbforge->add_key('id', TRUE); + $this->dbforge->add_key('plugin_slug', TRUE); + $this->dbforge->create_table('plugins'); + } + } + + public function down() + { + if ($this->db->table_exists('plugins')) { + $this->dbforge->drop_table('plugins'); + } + } +} diff --git a/application/migrations/269_set_tevel2_series_to_notsent.php b/application/migrations/269_set_tevel2_series_to_notsent.php new file mode 100644 index 000000000..518b2bc72 --- /dev/null +++ b/application/migrations/269_set_tevel2_series_to_notsent.php @@ -0,0 +1,31 @@ +db->set('COL_LOTW_QSL_SENT', 'N'); + $this->db->where_in('COL_SAT_NAME', $satellites); + $this->db->update($this->config->item('table_name')); + + log_message('info', 'Migration: Set COL_LOTW_QSL_SENT to N for TEVEL2-1 through TEVEL2-9'); + } + + public function down() + { + // Not possible to safely restore previous per-QSO sent state. + } +} diff --git a/application/migrations/270_add_remote_operation_flag.php b/application/migrations/270_add_remote_operation_flag.php new file mode 100644 index 000000000..b08bd7a85 --- /dev/null +++ b/application/migrations/270_add_remote_operation_flag.php @@ -0,0 +1,25 @@ + array( + 'type' => 'TINYINT', + 'constraint' => 1, + 'null' => false, + 'default' => 0, + 'after' => 'winkey_websocket', + ), + ); + + $this->dbforge->add_column($this->config->item('auth_table'), $fields); + } + + public function down() + { + $this->dbforge->drop_column($this->config->item('auth_table'), 'remote_operation'); + } +} \ No newline at end of file diff --git a/application/migrations/271_tag_2_8_14.php b/application/migrations/271_tag_2_8_14.php new file mode 100644 index 000000000..ea2c571d6 --- /dev/null +++ b/application/migrations/271_tag_2_8_14.php @@ -0,0 +1,30 @@ +db->where('option_name', 'version'); + $this->db->update('options', array('option_value' => '2.8.14')); + + // Trigger Version Info Dialog + $this->db->where('option_type', 'version_dialog'); + $this->db->where('option_name', 'confirmed'); + $this->db->update('user_options', array('option_value' => 'false')); + + } + + public function down() + { + $this->db->where('option_name', 'version'); + $this->db->update('options', array('option_value' => '2.8.13')); + } +} \ No newline at end of file diff --git a/application/models/Dxcc.php b/application/models/Dxcc.php index 3ad61cfe9..4a1585beb 100644 --- a/application/models/Dxcc.php +++ b/application/models/Dxcc.php @@ -155,6 +155,7 @@ function getDxccBandConfirmed($location_list, $band, $postdata) { $sql .= " and (col_mode = '" . $postdata['mode'] . "' or col_submode = '" . $postdata['mode'] . "')"; } + $sql .= $this->addYearToQuery($postdata); $sql .= $this->addQslToQuery($postdata); $sql .= " group by col_dxcc @@ -184,6 +185,8 @@ function getDxccBandWorked($location_list, $band, $postdata) { $sql .= " and (col_mode = '" . $postdata['mode'] . "' or col_submode = '" . $postdata['mode'] . "')"; } + $sql .= $this->addYearToQuery($postdata); + $sql .= " group by col_dxcc ) x on dxcc_entities.adif = x.col_dxcc";; @@ -211,6 +214,29 @@ function addBandToQuery($band) { return $sql; } + function addYearToQuery($postdata) { + $sql = ''; + if (!empty($postdata['year']) && $postdata['year'] !== 'All') { + $year = (int) $postdata['year']; + $sql .= " and YEAR(col_time_on) = " . $year; + } + return $sql; + } + + function get_worked_years() { + $CI =& get_instance(); + $CI->load->model('logbooks_model'); + $logbooks_locations_array = $CI->logbooks_model->list_logbook_relationships($this->session->userdata('active_station_logbook')); + if (!$logbooks_locations_array) { + return []; + } + $location_list = "'" . implode("','", $logbooks_locations_array) . "'"; + $sql = "SELECT DISTINCT YEAR(col_time_on) as year FROM " . $this->config->item('table_name') . + " WHERE station_id IN (" . $location_list . ") AND col_dxcc > 0 AND col_time_on IS NOT NULL ORDER BY year DESC"; + $query = $this->db->query($sql); + return array_column($query->result_array(), 'year'); + } + function fetchDxcc($postdata) { $CI =& get_instance(); $CI->load->model('logbooks_model'); @@ -273,6 +299,8 @@ function getDxccWorked($location_list, $postdata) { $sql .= " and (col_mode = '" . $postdata['mode'] . "' or col_submode = '" . $postdata['mode'] . "')"; } + $sql .= $this->addYearToQuery($postdata); + $sql .= " and not exists (select 1 from ".$this->config->item('table_name')." where station_id in (". $location_list .") and col_dxcc = thcv.col_dxcc and col_dxcc > 0"; $sql .= $this->addBandToQuery($postdata['band']); @@ -281,6 +309,8 @@ function getDxccWorked($location_list, $postdata) { $sql .= " and (col_mode = '" . $postdata['mode'] . "' or col_submode = '" . $postdata['mode'] . "')"; } + $sql .= $this->addYearToQuery($postdata); + $sql .= $this->addQslToQuery($postdata); $sql .= ')'; @@ -314,6 +344,7 @@ function getDxccConfirmed($location_list, $postdata) { $sql .= " and (col_mode = '" . $postdata['mode'] . "' or col_submode = '" . $postdata['mode'] . "')"; } + $sql .= $this->addYearToQuery($postdata); $sql .= $this->addQslToQuery($postdata); $sql .= " group by col_dxcc diff --git a/application/models/Gmdxsummer_model.php b/application/models/Gmdxsummer_model.php index 70f6e6427..4d7dfe2ca 100644 --- a/application/models/Gmdxsummer_model.php +++ b/application/models/Gmdxsummer_model.php @@ -2,6 +2,7 @@ class Gmdxsummer_model extends CI_Model { + private const START_DATE = '2026-05-11 00:00:00'; public function get_week($end_date, $band, $mode) { @@ -17,7 +18,8 @@ public function get_week($end_date, $band, $mode) SELECT COUNT(DISTINCT SUBSTRING(COL_GRIDSQUARE, 1, 4)) AS count FROM " . $table_name . " WHERE station_id in (" . $location_list . ") AND COL_MODE = '" . $mode . "' AND COL_BAND = '" . $band . "' - AND (COL_TIME_ON >= '2024-05-13 00:00:00' AND COL_TIME_ON <= '" . $end_date . "') + AND COL_GRIDSQUARE IS NOT NULL AND COL_GRIDSQUARE != '' + AND (COL_TIME_ON >= '" . self::START_DATE . "' AND COL_TIME_ON <= '" . $end_date . "') "); return $query->row()->count; @@ -37,7 +39,8 @@ public function get_week_voice($end_date, $band) SELECT COUNT(DISTINCT SUBSTRING(COL_GRIDSQUARE, 1, 4)) AS count FROM " . $table_name . " WHERE station_id in (".$location_list.") AND COL_MODE IN ('SSB', 'AM', 'FM') AND COL_BAND = '" . $band . "' - AND (COL_TIME_ON >= '2024-05-13 00:00:00' AND COL_TIME_ON <= '" . $end_date . "') + AND COL_GRIDSQUARE IS NOT NULL AND COL_GRIDSQUARE != '' + AND (COL_TIME_ON >= '" . self::START_DATE . "' AND COL_TIME_ON <= '" . $end_date . "') "); @@ -58,7 +61,8 @@ public function get_week_digital($end_date, $band) SELECT COUNT(DISTINCT SUBSTRING(COL_GRIDSQUARE, 1, 4)) AS count FROM " . $table_name . " WHERE station_id in (".$location_list.") AND COL_MODE NOT IN ('CW', 'FM', 'SSB', 'AM') AND COL_BAND = '" . $band . "' - AND (COL_TIME_ON >= '2024-05-13 00:00:00' AND COL_TIME_ON <= '" . $end_date . "') + AND COL_GRIDSQUARE IS NOT NULL AND COL_GRIDSQUARE != '' + AND (COL_TIME_ON >= '" . self::START_DATE . "' AND COL_TIME_ON <= '" . $end_date . "') "); return $query->row()->count; @@ -75,10 +79,19 @@ public function get_week_combined($end_date, $band) $location_list = "'" . implode("','", $logbooks_locations_array) . "'"; $query = $this->db->query(" - SELECT COUNT(DISTINCT SUBSTRING(COL_GRIDSQUARE, 1, 4)) AS count + SELECT COUNT(DISTINCT CONCAT( + UPPER(SUBSTRING(COL_GRIDSQUARE, 1, 4)), + '-', + CASE + WHEN COL_MODE = 'CW' THEN 'CW' + WHEN COL_MODE IN ('SSB', 'AM', 'FM') THEN 'VOICE' + ELSE 'DIGITAL' + END + )) AS count FROM " . $table_name . " WHERE station_id in (".$location_list.") AND COL_BAND = '" . $band . "' - AND (COL_TIME_ON >= '2024-05-13 00:00:00' AND COL_TIME_ON <= '" . $end_date . "') + AND COL_GRIDSQUARE IS NOT NULL AND COL_GRIDSQUARE != '' + AND (COL_TIME_ON >= '" . self::START_DATE . "' AND COL_TIME_ON <= '" . $end_date . "') "); return $query->row()->count; diff --git a/application/models/Logbook_model.php b/application/models/Logbook_model.php index c3d7db787..e9d894d76 100755 --- a/application/models/Logbook_model.php +++ b/application/models/Logbook_model.php @@ -443,6 +443,16 @@ function create_qso() $data['COL_LOTW_QSL_RCVD'] = 'N'; } + $this->load->library('cloudlog_hooks'); + $filtered_data = $this->cloudlog_hooks->apply_filters('qso.filter.before_save', $data, array( + 'source' => 'manual', + 'station_id' => $station_id, + )); + + if (is_array($filtered_data)) { + $data = $filtered_data; + } + $this->add_qso($data, $skipexport = false); } @@ -739,13 +749,24 @@ function add_qso($data, $skipexport = false) $last_id = $this->db->insert_id(); - // Clear dashboard cache for affected station - $this->clear_dashboard_cache($data['station_id']); + // Clear dashboard cache for affected station (skipped during bulk import - cleared once at end) + if (!$skipexport) { + $this->clear_dashboard_cache($data['station_id']); + } if ($this->session->userdata('user_amsat_status_upload') && $data['COL_PROP_MODE'] == "SAT") { $this->upload_amsat_status($data); } + $this->load->library('cloudlog_hooks'); + $this->cloudlog_hooks->do_action('qso.action.after_save', array( + 'qso_id' => $last_id, + 'qso' => $data, + ), array( + 'source' => $skipexport ? 'import' : 'manual', + 'station_id' => isset($data['station_id']) ? $data['station_id'] : null, + )); + // No point in fetching hrdlog code or qrz api key and qrzrealtime setting if we're skipping the export if (!$skipexport) { @@ -1529,6 +1550,14 @@ function edit() // Clear dashboard cache for affected station $this->clear_dashboard_cache($stationId); + + $this->load->library('cloudlog_hooks'); + $this->cloudlog_hooks->do_action('qso.action.after_edit', array( + 'qso_id' => (int)$this->input->post('id'), + 'qso' => $data, + ), array( + 'station_id' => $stationId, + )); } /* QSL received */ @@ -4005,11 +4034,13 @@ function import_bulk($records, $station_id = "0", $skipDuplicate = false, $markC { $custom_errors = ''; foreach ($records as $record) { - $one_error = $this->logbook_model->import($record, $station_id, $skipDuplicate, $markClublog, $markLotw, $dxccAdif, $markQrz, $markHrd, $skipexport, $operatorName, $apicall, $skipStationCheck); + $one_error = $this->logbook_model->import($record, $station_id, $skipDuplicate, $markClublog, $markLotw, $dxccAdif, $markQrz, $markHrd, $skipexport, $operatorName, $apicall, $skipStationCheck, true); if ($one_error != '') { $custom_errors .= $one_error . "
"; } } + // Clear dashboard cache once after all records are imported, rather than after every single QSO + $this->clear_dashboard_cache($station_id); return $custom_errors; } /* @@ -4020,7 +4051,7 @@ function import_bulk($records, $station_id = "0", $skipDuplicate = false, $markC * $markHrd - used in ADIF import to mark QSOs as exported to HRDLog.net Logbook when importing QSOs * $skipexport - used in ADIF import to skip the realtime upload to QRZ Logbook when importing QSOs from ADIF */ - function import($record, $station_id = "0", $skipDuplicate = false, $markClublog = false, $markLotw = false, $dxccAdif = false, $markQrz = false, $markHrd = false, $skipexport = false, $operatorName = false, $apicall = false, $skipStationCheck = false) + function import($record, $station_id = "0", $skipDuplicate = false, $markClublog = false, $markLotw = false, $dxccAdif = false, $markQrz = false, $markHrd = false, $skipexport = false, $operatorName = false, $apicall = false, $skipStationCheck = false, $skipCacheClear = false) { // be sure that station belongs to user $this->load->model('stations'); diff --git a/application/models/Logbooks_model.php b/application/models/Logbooks_model.php index 78cbd78eb..f3a0e9b78 100644 --- a/application/models/Logbooks_model.php +++ b/application/models/Logbooks_model.php @@ -4,10 +4,32 @@ class Logbooks_model extends CI_Model { // Cache for list_logbook_relationships to avoid redundant queries private $relationship_cache = array(); + private $slp_permission_column = null; + + private function get_slp_permission_column() { + if ($this->slp_permission_column !== null) { + return $this->slp_permission_column; + } + + if ($this->db->field_exists('permission_level', 'station_logbooks_permissions')) { + $this->slp_permission_column = 'permission_level'; + return $this->slp_permission_column; + } + + if ($this->db->field_exists('access_level', 'station_logbooks_permissions')) { + $this->slp_permission_column = 'access_level'; + return $this->slp_permission_column; + } + + // Legacy fallback to avoid hard failures on partially migrated instances. + $this->slp_permission_column = 'permission'; + return $this->slp_permission_column; + } function show_all() { // Get owned logbooks and shared logbooks with access level $user_id = $this->session->userdata('user_id'); + $permission_column = $this->get_slp_permission_column(); // If no user is logged in, return empty result if ($user_id === NULL || $user_id === FALSE) { @@ -19,7 +41,7 @@ function show_all() { $this->db->select('station_logbooks.*, CASE WHEN station_logbooks.user_id = '.$this->db->escape($user_id).' THEN "owner" - ELSE slp.permission_level + ELSE slp.'.$permission_column.' END as access_level', FALSE); $this->db->from('station_logbooks'); $this->db->join('station_logbooks_permissions slp', @@ -258,11 +280,12 @@ function public_slugs_by_user($user_id) { function public_slugs_accessible_by_user($user_id) { $clean_user_id = $this->security->xss_clean($user_id); + $permission_column = $this->get_slp_permission_column(); $this->db->select('station_logbooks.logbook_id, station_logbooks.logbook_name, station_logbooks.public_slug, CASE WHEN station_logbooks.user_id = '.$this->db->escape($clean_user_id).' THEN "owner" - ELSE slp.permission_level + ELSE slp.'.$permission_column.' END as access_level', FALSE); $this->db->from('station_logbooks'); $this->db->join('station_logbooks_permissions slp', @@ -425,6 +448,7 @@ function delete_relationship($logbook_id, $station_id) { public function check_logbook_is_accessible($id, $min_level = 'read') { // First check if user is the owner (existing behavior - highest priority) + $permission_column = $this->get_slp_permission_column(); $this->db->select('logbook_id'); $this->db->where('user_id', $this->session->userdata('user_id')); $this->db->where('logbook_id', $id); @@ -434,7 +458,7 @@ public function check_logbook_is_accessible($id, $min_level = 'read') { } // Check if user has shared access via permissions table - $this->db->select('permission_level'); + $this->db->select($permission_column.' as permission_level', FALSE); $this->db->where('logbook_id', $id); $this->db->where('user_id', $this->session->userdata('user_id')); $query = $this->db->get('station_logbooks_permissions'); @@ -465,6 +489,7 @@ public function is_logbook_owner($id) { public function get_user_permission($logbook_id, $user_id) { // Get the permission level for a specific user on a specific logbook // Returns 'owner', permission level, or null + $permission_column = $this->get_slp_permission_column(); // Check if user is owner $this->db->select('logbook_id'); @@ -476,7 +501,7 @@ public function get_user_permission($logbook_id, $user_id) { } // Check permissions table - $this->db->select('permission_level'); + $this->db->select($permission_column.' as permission_level', FALSE); $this->db->where('logbook_id', $logbook_id); $this->db->where('user_id', $user_id); $query = $this->db->get('station_logbooks_permissions'); @@ -492,6 +517,7 @@ public function add_logbook_permission($logbook_id, $user_id, $permission_level // Add a user to a logbook with specified permission level // Only owner or admin can add users // Returns array with 'success' and 'is_new' keys + $permission_column = $this->get_slp_permission_column(); $clean_logbook_id = $this->security->xss_clean($logbook_id); $clean_user_id = $this->security->xss_clean($user_id); @@ -519,7 +545,7 @@ public function add_logbook_permission($logbook_id, $user_id, $permission_level if ($existing->num_rows() > 0) { // Update existing permission $data = array( - 'permission_level' => $clean_permission, + $permission_column => $clean_permission, 'modified' => date('Y-m-d H:i:s'), ); $this->db->where('logbook_id', $clean_logbook_id); @@ -530,7 +556,7 @@ public function add_logbook_permission($logbook_id, $user_id, $permission_level $data = array( 'logbook_id' => $clean_logbook_id, 'user_id' => $clean_user_id, - 'permission_level' => $clean_permission, + $permission_column => $clean_permission, ); $this->db->insert('station_logbooks_permissions', $data); } @@ -561,6 +587,7 @@ public function remove_logbook_permission($logbook_id, $user_id) { public function list_logbook_collaborators($logbook_id) { // Get all users with access to a logbook (excluding owner) // Returns array of users with their permission levels + $permission_column = $this->get_slp_permission_column(); $clean_logbook_id = $this->security->xss_clean($logbook_id); @@ -572,7 +599,7 @@ public function list_logbook_collaborators($logbook_id) { $owner_query = $this->db->get(); // Get shared users - $this->db->select('slp.user_id, users.user_callsign, users.user_email, slp.permission_level, slp.created_at'); + $this->db->select('slp.user_id, users.user_callsign, users.user_email, slp.'.$permission_column.' as permission_level, slp.created_at', FALSE); $this->db->from('station_logbooks_permissions slp'); $this->db->join('users', 'users.user_id = slp.user_id'); $this->db->where('slp.logbook_id', $clean_logbook_id); diff --git a/application/models/Plugins_model.php b/application/models/Plugins_model.php new file mode 100644 index 000000000..fbdf517a5 --- /dev/null +++ b/application/models/Plugins_model.php @@ -0,0 +1,101 @@ +db->table_exists('plugins'); + } + + public function get_all() + { + if (!$this->table_exists()) { + return array(); + } + + $this->db->order_by('plugin_name', 'ASC'); + return $this->db->get('plugins')->result(); + } + + public function get_enabled() + { + if (!$this->table_exists()) { + return array(); + } + + $this->db->where('plugin_status', 'enabled'); + return $this->db->get('plugins')->result(); + } + + public function get_by_slug($slug) + { + if (!$this->table_exists()) { + return null; + } + + $this->db->where('plugin_slug', $slug); + $query = $this->db->get('plugins'); + + return $query->num_rows() > 0 ? $query->row() : null; + } + + public function upsert_plugin($slug, $manifest, $status = 'disabled') + { + if (!$this->table_exists()) { + return false; + } + + $now = date('Y-m-d H:i:s'); + $existing = $this->get_by_slug($slug); + + $data = array( + 'plugin_slug' => $slug, + 'plugin_name' => isset($manifest['name']) ? (string)$manifest['name'] : $slug, + 'plugin_version' => isset($manifest['version']) ? (string)$manifest['version'] : '1.0.0', + 'plugin_description' => isset($manifest['description']) ? (string)$manifest['description'] : null, + 'plugin_manifest' => json_encode($manifest), + 'updated_at' => $now, + ); + + if ($existing) { + if ($existing->plugin_status === 'enabled') { + $data['plugin_status'] = 'enabled'; + } else { + $data['plugin_status'] = $status; + } + + $this->db->where('plugin_slug', $slug); + return $this->db->update('plugins', $data); + } + + $data['plugin_status'] = $status; + $data['installed_at'] = $now; + + return $this->db->insert('plugins', $data); + } + + public function set_status($slug, $status) + { + if (!$this->table_exists()) { + return false; + } + + $this->db->where('plugin_slug', $slug); + return $this->db->update('plugins', array( + 'plugin_status' => $status, + 'updated_at' => date('Y-m-d H:i:s'), + )); + } + + public function delete_by_slug($slug) + { + if (!$this->table_exists()) { + return false; + } + + $this->db->where('plugin_slug', $slug); + return $this->db->delete('plugins'); + } +} diff --git a/application/models/User_model.php b/application/models/User_model.php index 731cc3a4e..1912cf832 100644 --- a/application/models/User_model.php +++ b/application/models/User_model.php @@ -202,7 +202,8 @@ function add($username, $password, $email, $type, $firstname, $lastname, $callsi $user_pota_lookup, $user_show_notes, $user_column1, $user_column2, $user_column3, $user_column4, $user_column5, $user_show_profile_image, $user_previous_qsl_type, $user_amsat_status_upload, $user_mastodon_url, $user_default_band, $user_default_confirmation, $user_qso_end_times, $user_quicklog, $user_quicklog_enter, - $language, $user_hamsat_key, $user_hamsat_workable_only, $callbook_type, $callbook_username, $callbook_password) { + $language, $user_hamsat_key, $user_hamsat_workable_only, $callbook_type, $callbook_username, $callbook_password, + $user_winkey, $user_winkey_websocket, $user_remote_operation) { // Check that the user isn't already used if(!$this->exists($username)) { $data = array( @@ -238,6 +239,9 @@ function add($username, $password, $email, $type, $firstname, $lastname, $callsi 'user_quicklog' => (int)$user_quicklog, 'user_quicklog_enter' => xss_clean($user_quicklog_enter), 'language' => xss_clean($language), + 'winkey' => (int)$user_winkey, + 'winkey_websocket' => (int)$user_winkey_websocket, + 'remote_operation' => (int)$user_remote_operation, 'user_eqsl_qth_nickname' => "", ); @@ -326,6 +330,7 @@ function edit($fields) { 'language' => xss_clean($fields['language']), 'winkey' => (isset($fields['user_winkey']) && is_numeric($clean = xss_clean($fields['user_winkey'])) && $clean !== '') ? intval($clean) : 0, 'winkey_websocket' => isset($fields['user_winkey_websocket']) ? xss_clean($fields['user_winkey_websocket']) : 0, + 'remote_operation' => isset($fields['user_remote_operation']) ? xss_clean($fields['user_remote_operation']) : 0, ); $this->db->query("replace into user_options (user_id, option_type, option_name, option_key, option_value) values (" . $fields['id'] . ", 'hamsat','hamsat_key','api','".xss_clean($fields['user_hamsat_key'])."');"); @@ -430,6 +435,11 @@ function update_session($id) { $CI =& get_instance(); $CI->load->model('user_options_model'); $callbook_type_object = $CI->user_options_model->get_options('callbook')->result(); + $remote_operation_option = $CI->user_options_model->get_options( + 'remote_operation', + array('option_name' => 'enabled', 'option_key' => 'value'), + $id + )->row(); $show_qsl_cards_option = $CI->user_options_model->get_options( 'menu', array('option_name' => 'show_qsl_cards', 'option_key' => 'enabled'), @@ -514,7 +524,8 @@ function update_session($id) { 'active_station_logbook' => $u->row()->active_station_logbook, 'language' => isset($u->row()->language) ? $u->row()->language: 'english', 'isWinkeyEnabled' => $u->row()->winkey, - 'isWinkeyWebsocketEnabled' => (bool)$u->row()->winkey_websocket, + 'isWinkeyWebsocketEnabled' => isset($u->row()->winkey_websocket) ? (bool)$u->row()->winkey_websocket : false, + 'isRemoteOperationEnabled' => (isset($remote_operation_option->option_value) ? ((string)$remote_operation_option->option_value === 'true' || (string)$remote_operation_option->option_value === '1') : (isset($u->row()->remote_operation) ? (bool)$u->row()->remote_operation : false)), 'hasQrzKey' => $this->hasQrzKey($u->row()->user_id), 'callbook_type' => $callbook_type, 'callbook_username' => $callbook_username, diff --git a/application/plugins/index.html b/application/plugins/index.html new file mode 100644 index 000000000..0c44a9d3d --- /dev/null +++ b/application/plugins/index.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/application/views/awards/dxcc/index.php b/application/views/awards/dxcc/index.php index 2a5695113..850f5483a 100644 --- a/application/views/awards/dxcc/index.php +++ b/application/views/awards/dxcc/index.php @@ -298,6 +298,18 @@ +
+ +
+ +
+
+
diff --git a/application/views/awards/gmdxsummer/index.php b/application/views/awards/gmdxsummer/index.php index 98d6592d4..eb51503fd 100644 --- a/application/views/awards/gmdxsummer/index.php +++ b/application/views/awards/gmdxsummer/index.php @@ -12,11 +12,11 @@ What is the GMDX Summer Challenge?
-

The GMDX Summer Challenge will takes place over 6 weeks beginning 00:00 on Monday 13th May until 2359 on Sunday 30th June.

-

The Summer Challenge is a single event using CW, SSB or Digital Modes on both 4m and 6m. GMDX members are expected not to set up FT8 in automated robot mode. You are trusted to operate your station manually.

-

The overall winner will have worked the most 4 digit maidenhead locator squares (eg IO75) on 4m and 6m to give the highest combined score. DXCC Countries are not counted, only locator squares. In order to have the leading score, it is expected operators would have to make contacts on both 4m and 6m using all modes.

+

The GMDX Summer VHF Challenge 2026 takes place from 00:00 on Monday 11th May until 23:59 on Sunday 5th July.

+

The challenge is a single event using CW, Voice, and Digital modes on both 4m and 6m. Acceptable voice modes are SSB, AM, and FM. Entrants are expected not to set up FT8 or other digital modes in automated robot mode, and to operate manually.

+

Score is based on 4-digit Maidenhead locator squares (for example IO72), and DXCC countries are not counted. The same locator can be counted once per mode category (CW, Voice, Digital) on each band, so the same locator can count up to 3 times on 4m and again up to 3 times on 6m.

All GMDX members worldwide are welcome to enter.

- Submit your entry + Submit your entry
@@ -39,7 +39,7 @@ - 18:00 on Sunday 26th May + 18:00 on Sunday 24th May @@ -51,7 +51,7 @@ - 18:00 on Sunday 9th June + 18:00 on Sunday 7th June @@ -63,7 +63,7 @@ - 18:00 on Sunday 23th June + 18:00 on Sunday 21st June @@ -75,7 +75,7 @@ - 18:00 on Monday 1st July + 18:00 on Monday 6th July (QSOs counted to 23:59 Sunday 5th July) diff --git a/application/views/awards/settings.php b/application/views/awards/settings.php index 832655915..85a2067a0 100644 --- a/application/views/awards/settings.php +++ b/application/views/awards/settings.php @@ -221,6 +221,30 @@ WWFF World Wide Flora and Fauna + + + Plugin Awards + + + + + + > + + + Plugin Award () + + + diff --git a/application/views/components/dashboard_logbook_table.php b/application/views/components/dashboard_logbook_table.php index 9dc5bff1a..e0cdbce01 100644 --- a/application/views/components/dashboard_logbook_table.php +++ b/application/views/components/dashboard_logbook_table.php @@ -1,161 +1,5 @@ ' . $ctx->lang->line('gen_hamradio_mode') . ''; - break; - case 'RSTS': - echo '' . $ctx->lang->line('gen_hamradio_rsts') . ''; - break; - case 'RSTR': - echo '' . $ctx->lang->line('gen_hamradio_rstr') . ''; - break; - case 'Country': - echo '' . $ctx->lang->line('general_word_country') . ''; - break; - case 'IOTA': - echo '' . $ctx->lang->line('gen_hamradio_iota') . ''; - break; - case 'SOTA': - echo '' . $ctx->lang->line('gen_hamradio_sota') . ''; - break; - case 'WWFF': - echo '' . $ctx->lang->line('gen_hamradio_wwff') . ''; - break; - case 'POTA': - echo '' . $ctx->lang->line('gen_hamradio_pota') . ''; - break; - case 'State': - echo '' . $ctx->lang->line('gen_hamradio_state') . ''; - break; - case 'Grid': - echo '' . $ctx->lang->line('gen_hamradio_gridsquare') . ''; - break; - case 'Distance': - echo '' . $ctx->lang->line('gen_hamradio_distance') . ''; - break; - case 'Band': - echo '' . $ctx->lang->line('gen_hamradio_band') . ''; - break; - case 'Frequency': - echo '' . $ctx->lang->line('gen_hamradio_frequency') . ''; - break; - case 'Operator': - echo '' . $ctx->lang->line('gen_hamradio_operator') . ''; - break; - case 'Name': - echo '' . $ctx->lang->line('general_word_name') . ''; - break; - case 'Flag': - echo ' '; - break; - } -} - -function echo_table_col($row, $name) -{ - $ci = &get_instance(); - switch ($name) { - case 'Mode': - echo ''; - echo $row->COL_SUBMODE == null ? $row->COL_MODE : $row->COL_SUBMODE . ''; - break; - case 'RSTS': - echo '' . $row->COL_RST_SENT; - if ($row->COL_STX) { - echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">'; - printf("%03d", $row->COL_STX); - echo ''; - } - if ($row->COL_STX_STRING) { - echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">' . $row->COL_STX_STRING . ''; - } - echo ''; - break; - case 'RSTR': - echo '' . $row->COL_RST_RCVD; - if ($row->COL_SRX) { - echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">'; - printf("%03d", $row->COL_SRX); - echo ''; - } - if ($row->COL_SRX_STRING) { - echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">' . $row->COL_SRX_STRING . ''; - } - echo ''; - break; - case 'Country': - echo '' . ucwords(strtolower(($row->COL_COUNTRY))); - if ($row->end != NULL) echo ' ' . $ci->lang->line('gen_hamradio_deleted_dxcc') . '' . ''; - break; - case 'IOTA': - echo '' . ($row->COL_IOTA) . ''; - break; - case 'SOTA': - echo '' . ($row->COL_SOTA_REF) . ''; - break; - case 'WWFF': - echo '' . ($row->COL_WWFF_REF) . ''; - break; - case 'POTA': - echo '' . ($row->COL_POTA_REF) . ''; - break; - case 'Grid': - echo ''; - echoQrbCalcLink($row->station_gridsquare, $row->COL_VUCC_GRIDS, $row->COL_GRIDSQUARE); - echo ''; - break; - case 'Distance': - echo '' . ($row->COL_DISTANCE ? $row->COL_DISTANCE . ' km' : '') . ''; - break; - case 'Band': - echo ''; - if ($row->COL_SAT_NAME != null) { - echo '' . $row->COL_SAT_NAME . ''; - } else { - echo strtolower($row->COL_BAND); - } - echo ''; - break; - case 'Frequency': - echo ''; - if ($row->COL_SAT_NAME != null) { - echo '' . $row->COL_SAT_NAME . ''; - } else { - if ($row->COL_FREQ != null) { - echo $ci->frequency->hz_to_mhz($row->COL_FREQ); - } else { - echo strtolower($row->COL_BAND); - } - } - echo ''; - break; - case 'State': - echo '' . ($row->COL_STATE) . ''; - break; - case 'Operator': - echo '' . ($row->COL_OPERATOR) . ''; - break; - case 'Name': - echo '' . ($row->COL_NAME) . ''; - break; - case 'Flag': - $ci->load->library('DxccFlag'); - $flag = strtolower($ci->dxccflag->getISO($row->COL_DXCC)); - echo 'name))) . '">'; - break; - } -} - -function echoQrbCalcLink($mygrid, $grid, $vucc) -{ - if (!empty($grid)) { - echo $grid . ' '; - } else if (!empty($vucc)) { - echo $vucc . ' '; - } -} +if (!defined('BASEPATH')) exit('No direct script access allowed'); ?>
diff --git a/application/views/components/upcoming_dxccs.php b/application/views/components/upcoming_dxccs.php index c672993e2..a153b4446 100644 --- a/application/views/components/upcoming_dxccs.php +++ b/application/views/components/upcoming_dxccs.php @@ -9,6 +9,12 @@ ' . $ctx->lang->line('gen_hamradio_mode') . ''; - break; - case 'RSTS': - echo ''; - break; - case 'RSTR': - echo ''; - break; - case 'Country': - echo ''; - break; - case 'IOTA': - echo ''; - break; - case 'SOTA': - echo ''; - break; - case 'WWFF': - echo ''; - break; - case 'POTA': - echo ''; - break; - case 'State': - echo ''; - break; - case 'Grid': - echo ''; - break; - case 'Distance': - echo ''; - break; - case 'Band': - echo ''; - break; - case 'Frequency': - echo ''; - break; - case 'Operator': - echo ''; - break; - case 'Name': - echo ''; - break; - case 'Flag': - echo ''; - break; - } -} - -function echo_table_col($row, $name) -{ - $ci = &get_instance(); - switch ($name) { - case 'Mode': - echo ''; - break; - case 'RSTS': - echo ''; - break; - case 'RSTR': - echo ''; - break; - case 'Country': - echo ''; - break; - case 'IOTA': - echo ''; - break; - case 'SOTA': - echo ''; - break; - case 'WWFF': - echo ''; - break; - case 'POTA': - echo ''; - break; - case 'Grid': - echo ''; - break; - case 'Distance': - echo ''; - break; - case 'Band': - echo ''; - } else { - echo strtolower($row->COL_BAND); - } - echo ''; - break; - case 'Frequency': - echo ''; - } else { - if ($row->COL_FREQ != null) { - echo $ci->frequency->hz_to_mhz($row->COL_FREQ); - } else { - echo strtolower($row->COL_BAND); - } - } - echo ''; - break; - case 'State': - echo ''; - break; - case 'Operator': - echo ''; - break; - case 'Name': - echo ''; - break; - case 'Flag': - $ci->load->library('DxccFlag'); - $flag = strtolower($ci->dxccflag->getISO($row->COL_DXCC)); - echo ''; - break; - } -} - -function echoQrbCalcLink($mygrid, $grid, $vucc) -{ - if (!empty($grid)) { - echo $grid . ' '; - } else if (!empty($vucc)) { - echo $vucc . ' '; - } -} -?> +
+ config->item('use_auth') && ($this->session->userdata('user_type') >= 2)) || $this->config->item('use_auth') === FALSE) { ?> @@ -185,7 +32,7 @@ function echoQrbCalcLink($mygrid, $grid, $vucc) optionslib->get_option('dashboard_banner') != "false") { ?> -
+
= 1) { ?>
' . $ctx->lang->line('gen_hamradio_rsts') . '' . $ctx->lang->line('gen_hamradio_rstr') . '' . $ctx->lang->line('general_word_country') . '' . $ctx->lang->line('gen_hamradio_iota') . '' . $ctx->lang->line('gen_hamradio_sota') . '' . $ctx->lang->line('gen_hamradio_wwff') . '' . $ctx->lang->line('gen_hamradio_pota') . '' . $ctx->lang->line('gen_hamradio_state') . '' . $ctx->lang->line('gen_hamradio_gridsquare') . '' . $ctx->lang->line('gen_hamradio_distance') . '' . $ctx->lang->line('gen_hamradio_band') . '' . $ctx->lang->line('gen_hamradio_frequency') . '' . $ctx->lang->line('gen_hamradio_operator') . '' . $ctx->lang->line('general_word_name') . ' '; - echo $row->COL_SUBMODE == null ? $row->COL_MODE : $row->COL_SUBMODE . '' . $row->COL_RST_SENT; - if ($row->COL_STX) { - echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">'; - printf("%03d", $row->COL_STX); - echo ''; - } - if ($row->COL_STX_STRING) { - echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">' . $row->COL_STX_STRING . ''; - } - echo '' . $row->COL_RST_RCVD; - if ($row->COL_SRX) { - echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">'; - printf("%03d", $row->COL_SRX); - echo ''; - } - if ($row->COL_SRX_STRING) { - echo ' COL_CONTEST_ID : "n/a") . '" class="badge text-bg-light">' . $row->COL_SRX_STRING . ''; - } - echo '' . ucwords(strtolower(($row->COL_COUNTRY))); - if ($row->end != NULL) echo ' ' . $ci->lang->line('gen_hamradio_deleted_dxcc') . '' . '' . ($row->COL_IOTA) . '' . ($row->COL_SOTA_REF) . '' . ($row->COL_WWFF_REF) . '' . ($row->COL_POTA_REF) . ''; - echoQrbCalcLink($row->station_gridsquare, $row->COL_VUCC_GRIDS, $row->COL_GRIDSQUARE); - echo '' . ($row->COL_DISTANCE ? $row->COL_DISTANCE . ' km' : '') . ''; - if ($row->COL_SAT_NAME != null) { - echo '' . $row->COL_SAT_NAME . ''; - if ($row->COL_SAT_NAME != null) { - echo '' . $row->COL_SAT_NAME . '' . ($row->COL_STATE) . '' . ($row->COL_OPERATOR) . '' . ($row->COL_NAME) . 'name))) . '">
@@ -281,9 +164,14 @@ function echoQrbCalcLink($mygrid, $grid, $vucc)
+
- - +
+ +
+
@@ -307,9 +195,16 @@ function echoQrbCalcLink($mygrid, $grid, $vucc)
+
config->item('use_auth') && ($this->session->userdata('user_type') >= 2)) || $this->config->item('use_auth') === FALSE) && ($total_qsl_sent != 0 || $total_qsl_rcvd != 0 || $total_qsl_requested != 0)) { ?> +
+ +
+
@@ -334,11 +229,18 @@ function echoQrbCalcLink($mygrid, $grid, $vucc)
+
config->item('use_auth') && ($this->session->userdata('user_type') >= 2)) || $this->config->item('use_auth') === FALSE) && ($total_eqsl_sent != 0 || $total_eqsl_rcvd != 0)) { ?> +
+ +
+
@@ -357,11 +259,18 @@ function echoQrbCalcLink($mygrid, $grid, $vucc)
+
config->item('use_auth') && ($this->session->userdata('user_type') >= 2)) || $this->config->item('use_auth') === false) && ($total_lotw_sent != 0 || $total_lotw_rcvd != 0)) { ?> +
+ +
+
@@ -380,10 +289,17 @@ function echoQrbCalcLink($mygrid, $grid, $vucc)
+
config->item('use_auth') && ($this->session->userdata('user_type') >= 2)) || $this->config->item('use_auth') === false) && ($total_qrz_sent != 0 || $total_qrz_rcvd != 0)) { ?> +
+ +
+
@@ -402,10 +318,17 @@ function echoQrbCalcLink($mygrid, $grid, $vucc)
QRZ.com
+
config->item('use_auth') && ($this->session->userdata('user_type') >= 2)) || $this->config->item('use_auth') === FALSE)) { ?> +
+ +
+
@@ -425,10 +348,57 @@ function echoQrbCalcLink($mygrid, $grid, $vucc)
VUCC-Grids
+
- \ No newline at end of file + + + + + diff --git a/application/views/interface_assets/footer.php b/application/views/interface_assets/footer.php index 323587f83..a9b8c4c68 100644 --- a/application/views/interface_assets/footer.php +++ b/application/views/interface_assets/footer.php @@ -1320,6 +1320,10 @@ function searchButtonPress() { + + session->userdata('isRemoteOperationEnabled')) { ?> + + session->userdata('isWinkeyEnabled') && !$this->session->userdata('isWinkeyWebsocketEnabled')) { ?> session->userdata('isWinkeyEnabled') && $this->session->userdata('isWinkeyWebsocketEnabled')) { ?> @@ -1353,41 +1357,262 @@ function toggleWinkeyVisibility() { diff --git a/application/views/qso/components/remoteoperationsettings.php b/application/views/qso/components/remoteoperationsettings.php new file mode 100644 index 000000000..97b8d11f3 --- /dev/null +++ b/application/views/qso/components/remoteoperationsettings.php @@ -0,0 +1,135 @@ + + diff --git a/application/views/qso/components/winkeysettings.php b/application/views/qso/components/winkeysettings.php index 7123fcc57..305c82645 100644 --- a/application/views/qso/components/winkeysettings.php +++ b/application/views/qso/components/winkeysettings.php @@ -1,91 +1,55 @@