From 2689d3e0c86cfe122b40427141015e05e0545040 Mon Sep 17 00:00:00 2001 From: Peter Goodhall Date: Tue, 28 Apr 2026 13:37:19 +0100 Subject: [PATCH 01/48] Bump migration to 267 and add 2.8.13 tag migration Increment migration_version to 267 and add Migration_tag_2_8_13. The new migration updates the 'options' table to set version = '2.8.13' and resets user_options (version_dialog confirmed) to 'false' to trigger the version info dialog; the down() method reverts the version to '2.8.12'. --- application/config/migration.php | 2 +- application/migrations/267_tag_2_8_13.php | 30 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 application/migrations/267_tag_2_8_13.php diff --git a/application/config/migration.php b/application/config/migration.php index ca0f54dc9..5bb94b1c6 100644 --- a/application/config/migration.php +++ b/application/config/migration.php @@ -22,7 +22,7 @@ | */ -$config['migration_version'] = 266; +$config['migration_version'] = 267; /* |-------------------------------------------------------------------------- diff --git a/application/migrations/267_tag_2_8_13.php b/application/migrations/267_tag_2_8_13.php new file mode 100644 index 000000000..280e57a19 --- /dev/null +++ b/application/migrations/267_tag_2_8_13.php @@ -0,0 +1,30 @@ +db->where('option_name', 'version'); + $this->db->update('options', array('option_value' => '2.8.13')); + + // 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.12')); + } +} \ No newline at end of file From c3b895fc2e0feb915b3a8f23e80f8d1e43aaceb1 Mon Sep 17 00:00:00 2001 From: phl0 Date: Wed, 29 Apr 2026 08:20:06 +0200 Subject: [PATCH 02/48] Suport bands from 23cm to 3cm --- assets/js/sections/simplefle.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/assets/js/sections/simplefle.js b/assets/js/sections/simplefle.js index 42aa7e7a1..c8847e8e7 100644 --- a/assets/js/sections/simplefle.js +++ b/assets/js/sections/simplefle.js @@ -659,6 +659,16 @@ function getBandFromFreq(freq) { return "2m"; } else if (freq > 430 && freq < 460) { return "70cm"; + } else if (freq > 1240 && freq < 1300) { + return "23cm"; + } else if (freq > 2300 && freq < 2450) { + return "13cm"; + } else if (freq > 3300 && freq < 3500) { + return "9cm"; + } else if (freq > 5650 && freq < 5925) { + return "6cm"; + } else if (freq > 10000 && freq < 10500) { + return "3cm"; } return ""; From f137a2ed265e95df66dc45c92ff758bb66c77b73 Mon Sep 17 00:00:00 2001 From: phl0 Date: Wed, 29 Apr 2026 09:27:05 +0200 Subject: [PATCH 03/48] Handle band and prop_mode properly --- assets/js/sections/simplefle.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/assets/js/sections/simplefle.js b/assets/js/sections/simplefle.js index c8847e8e7..a0605240e 100644 --- a/assets/js/sections/simplefle.js +++ b/assets/js/sections/simplefle.js @@ -240,9 +240,8 @@ function handleInput() { item.match(/^satellite$/i) || item.match(/^sat$/i) ) { - // Set band to SAT and prop_mode + // Set prop_mode to SAT console.log("SAT keyword detected"); - band = "SAT"; prop_mode = "SAT"; freq = 0; } else if (item.match(/^\d+\.\d+$/)) { @@ -267,7 +266,7 @@ function handleInput() { ) { sotaWwff = item.toUpperCase(); } else if ( - band === "SAT" && + prop_mode === "SAT" && item.match(/^[A-Z0-9]+-\d+[A-Z]*$/i) ) { // Satellite name (e.g., AO-7, ISS, FO-29) @@ -283,7 +282,7 @@ function handleInput() { console.log("Satellite NOT found in database. Available:", Object.keys(satelliteData).length, "satellites"); } } else if ( - band === "SAT" && + prop_mode === "SAT" && item.match(/^(ISS|ARISS)$/i) ) { // Handle satellites without numbers (ISS, ARISS) @@ -292,7 +291,7 @@ function handleInput() { // Update visual feedback updateSatelliteFeedback(sat_name, null); } else if ( - band === "SAT" && + prop_mode === "SAT" && sat_name && item.match(/^[UVLSCX](\/[UVLSCX])?$/i) ) { @@ -301,10 +300,11 @@ function handleInput() { // Update visual feedback with selected mode updateSatelliteFeedback(sat_name, sat_mode); // Now populate frequencies from satellite_data.json - console.log("Looking up satellite:", sat_name, "mode:", sat_mode, "band:", band); + console.log("Looking up satellite:", sat_name, "mode:", sat_mode); if (satelliteData[sat_name] && satelliteData[sat_name].Modes && satelliteData[sat_name].Modes[sat_mode]) { var modeData = satelliteData[sat_name].Modes[sat_mode][0]; freq = modeData.Uplink_Freq / 1000000; + band = getBandFromFreq(freq); freq_rx = modeData.Downlink_Freq / 1000000; band_rx = getBandFromFreq(freq_rx); @@ -315,7 +315,7 @@ function handleInput() { } else { mode = modeData.Uplink_Mode; } - console.log("Satellite data found. Freq:", freq, "Mode:", mode, "Band RX:", band_rx); + console.log("Satellite data found. Freq:", freq, "Mode:", mode, "Band:", band, "Band RX:", band_rx); } else { // Satellite mode not found in database, but still valid // User will need to manually set mode if not in database @@ -353,7 +353,7 @@ function handleInput() { console.log("Processing QSO for callsign:", callsign, "Band:", band, "Mode:", mode, "Freq:", freq, "Sat:", sat_name, sat_mode); // For satellite QSOs, freq should already be set from satellite mode lookup // For regular QSOs, calculate freq if not provided - if (band === "SAT") { + if (prop_mode === "SAT") { // Satellite QSO - freq and mode should be set from satellite mode lookup // If not set (satellite not in database), freq will be 0 or empty if (!freq || freq === 0 || freq === "") { From 96eee63bc860397e6d8a179878dc3200a1eb1146 Mon Sep 17 00:00:00 2001 From: Peter Goodhall Date: Wed, 29 Apr 2026 16:52:14 +0100 Subject: [PATCH 04/48] Update upcoming_dxccs.php --- application/views/components/upcoming_dxccs.php | 6 ++++++ 1 file changed, 6 insertions(+) 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 @@ Date: Wed, 29 Apr 2026 22:33:22 +0100 Subject: [PATCH 05/48] Add plugin system and Plugin Manager Introduce a plugin framework and management UI: adds Plugin Manager controller, Plugin_awards controller, Plugin_manager and Cloudlog_hooks libraries, Plugins_model, migration (268) to create the plugins table, and views for plugin manager and award pages. Integrates hooks into Logbook_model (qso.filter.before_save, qso.action.after_save, qso.action.after_edit), updates header to show award plugin entries and a Plugin Manager menu link, and bumps migration_version to 268. Also adds .gitignore rules, plugin index placeholder, docs and example plugin packages. The Plugin Manager supports uploading/installing ZIP packages, safe extraction, manifest validation, enable/disable/delete actions, and CSRF protection. --- .gitignore | 2 + application/config/migration.php | 2 +- application/controllers/Plugin_awards.php | 41 ++ application/controllers/Plugins.php | 161 +++++ application/libraries/Cloudlog_hooks.php | 159 +++++ application/libraries/Plugin_manager.php | 609 ++++++++++++++++++ .../migrations/268_create_plugins_table.php | 69 ++ application/models/Logbook_model.php | 27 + application/models/Plugins_model.php | 101 +++ application/plugins/index.html | 8 + application/views/interface_assets/header.php | 13 + application/views/plugins/award_page.php | 10 + application/views/plugins/index.php | 158 +++++ docs/awards-plugin-guide.md | 389 +++++++++++ docs/examples/plugins/award-73-on-73.zip | Bin 0 -> 4493 bytes .../plugins/award-73-on-73/Plugin.php | 244 +++++++ .../examples/plugins/award-73-on-73/README.md | 31 + .../plugins/award-73-on-73/manifest.json | 15 + .../plugins/club-awards-plus/Plugin.php | 80 +++ .../plugins/club-awards-plus/README.md | 30 + .../plugins/club-awards-plus/manifest.json | 20 + docs/plugin-manager-guide.md | 341 ++++++++++ docs/plugin-system-phase1.md | 192 ++++++ 23 files changed, 2701 insertions(+), 1 deletion(-) create mode 100644 application/controllers/Plugin_awards.php create mode 100644 application/controllers/Plugins.php create mode 100644 application/libraries/Cloudlog_hooks.php create mode 100644 application/libraries/Plugin_manager.php create mode 100644 application/migrations/268_create_plugins_table.php create mode 100644 application/models/Plugins_model.php create mode 100644 application/plugins/index.html create mode 100644 application/views/plugins/award_page.php create mode 100644 application/views/plugins/index.php create mode 100644 docs/awards-plugin-guide.md create mode 100644 docs/examples/plugins/award-73-on-73.zip create mode 100644 docs/examples/plugins/award-73-on-73/Plugin.php create mode 100644 docs/examples/plugins/award-73-on-73/README.md create mode 100644 docs/examples/plugins/award-73-on-73/manifest.json create mode 100644 docs/examples/plugins/club-awards-plus/Plugin.php create mode 100644 docs/examples/plugins/club-awards-plus/README.md create mode 100644 docs/examples/plugins/club-awards-plus/manifest.json create mode 100644 docs/plugin-manager-guide.md create mode 100644 docs/plugin-system-phase1.md diff --git a/.gitignore b/.gitignore index 9c704873a..61e9dafa7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ !/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..2377ca66b 100644 --- a/application/config/migration.php +++ b/application/config/migration.php @@ -22,7 +22,7 @@ | */ -$config['migration_version'] = 267; +$config['migration_version'] = 268; /* |-------------------------------------------------------------------------- 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..1c2aa1091 --- /dev/null +++ b/application/controllers/Plugins.php @@ -0,0 +1,161 @@ +load->model('user_model'); + if (!$this->user_model->authorize(99)) { + $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); + redirect('dashboard'); + } + + $this->load->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(); + + $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']); + + $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/libraries/Cloudlog_hooks.php b/application/libraries/Cloudlog_hooks.php new file mode 100644 index 000000000..36a65648d --- /dev/null +++ b/application/libraries/Cloudlog_hooks.php @@ -0,0 +1,159 @@ +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)) { + 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()); + } + } + + 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)) { + 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()); + } + } + } + + 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); + return null; + } + + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $class_name)) { + log_message('error', 'Plugin class invalid for ' . $slug); + 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); + return null; + } + + require_once $entry_path; + + if (!class_exists($class_name)) { + log_message('error', 'Plugin class not found: ' . $class_name . ' (' . $slug . ')'); + return null; + } + + try { + $instance = new $class_name($this->CI); + } catch (Throwable $e) { + log_message('error', 'Plugin construction failed (' . $slug . '): ' . $e->getMessage()); + return null; + } + + $this->plugin_instances[$slug] = $instance; + + return $instance; + } +} diff --git a/application/libraries/Plugin_manager.php b/application/libraries/Plugin_manager.php new file mode 100644 index 000000000..f1b8cd0d6 --- /dev/null +++ b/application/libraries/Plugin_manager.php @@ -0,0 +1,609 @@ +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); + + $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.'); + } + + $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)) { + return array('ok' => false, 'message' => 'Invalid plugin award method.'); + } + + $instance = $this->instantiate_plugin($plugin, $manifest); + if (!$instance) { + return array('ok' => false, 'message' => 'Unable to load plugin class.'); + } + + if (!method_exists($instance, $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()); + 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; + } + + require_once $entry_path; + + 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); + } +} 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/models/Logbook_model.php b/application/models/Logbook_model.php index c3d7db787..ed8118f8a 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); } @@ -746,6 +756,15 @@ function add_qso($data, $skipexport = false) $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 +1548,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 */ 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/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/interface_assets/header.php b/application/views/interface_assets/header.php index ffedfec0f..652e1b49e 100644 --- a/application/views/interface_assets/header.php +++ b/application/views/interface_assets/header.php @@ -261,6 +261,15 @@ + load->library('plugin_manager'); + $plugin_award_entries = $CI->plugin_manager->get_award_menu_entries(); + if (!empty($plugin_award_entries)) { + foreach ($plugin_award_entries as $plugin_award_entry) { ?> + + +