';
+ 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.