diff --git a/application/controllers/Dxcluster.php b/application/controllers/Dxcluster.php index 8b9771bac..b4a92e75c 100644 --- a/application/controllers/Dxcluster.php +++ b/application/controllers/Dxcluster.php @@ -12,19 +12,28 @@ class Dxcluster extends CI_Controller { function spots($band, $age = '', $de = '', $mode = 'All') { + // Sanitize inputs + $band = $this->security->xss_clean($band); + $mode = $this->security->xss_clean($mode); + if ($age == '') { $age = $this->optionslib->get_option('dxcluster_maxage') ?? 60; + } else { + $age = (int)$age; } + if ($de == '') { $de = $this->optionslib->get_option('dxcluster_decont') ?? 'EU'; + } else { + $de = $this->security->xss_clean($de); } $calls_found = $this->dxcluster_model->dxc_spotlist($band, $age, $de, $mode); header('Content-Type: application/json'); - if ($calls_found) { - echo json_encode($calls_found, JSON_PRETTY_PRINT); + if ($calls_found && !empty($calls_found)) { + echo json_encode($calls_found); } else { - echo '{ "error": "not found" }'; + echo json_encode(['error' => 'not found']); } } diff --git a/application/models/Dxcluster_model.php b/application/models/Dxcluster_model.php index 2a891b675..d3bea2b1e 100644 --- a/application/models/Dxcluster_model.php +++ b/application/models/Dxcluster_model.php @@ -31,6 +31,7 @@ class Dxcluster_model extends CI_Model { } } + // Main function to get spot list from DXCache and process it public function dxc_spotlist($band = '20m', $maxage = 60, $de = '', $mode = 'All') { $this->load->helper(array('psr4_autoloader')); @@ -65,7 +66,7 @@ class Dxcluster_model extends CI_Model { } else { $dxcache_url = $dxcache_url . '/spots/'.$band; } - // $this->load->model('logbooks_model'); lives in the autoloader + $this->load->model('logbook_model'); $logbooks_locations_array = $this->logbooks_model->list_logbook_relationships($this->session->userdata('active_station_logbook')); @@ -143,7 +144,7 @@ class Dxcluster_model extends CI_Model { $singlespot->mode = $this->get_mode($singlespot); // Apply mode filter early - if (($mode != 'All') && ($mode != $this->modefilter($singlespot, $mode))) { + if (($mode != 'All') && !$this->modefilter($singlespot, $mode)) { continue; } @@ -159,7 +160,7 @@ class Dxcluster_model extends CI_Model { $singlespot->age = $minutes; $singlespot->when_pretty = date($custom_date_format . " H:i", $spotTimestamp); - // DXCC lookups with memoization to avoid duplicate lookups + // Perform DXCC lookups using cached results to prevent redundant database queries if (!(property_exists($singlespot,'dxcc_spotted'))) { $spotted_call = $singlespot->spotted ?? ''; if (empty($spotted_call)) { @@ -172,6 +173,7 @@ class Dxcluster_model extends CI_Model { $singlespot->dxcc_spotted = (object)[ 'dxcc_id' => $dxcc['adif'] ?? 0, 'cont' => $dxcc['cont'] ?? '', + 'cqz' => $dxcc['cqz'] ?? '', 'flag' => '', 'entity' => $dxcc['entity'] ?? 'Unknown' ]; @@ -188,10 +190,12 @@ class Dxcluster_model extends CI_Model { $singlespot->dxcc_spotter = (object)[ 'dxcc_id' => $dxcc['adif'] ?? 0, 'cont' => $dxcc['cont'] ?? '', + 'cqz' => $dxcc['cqz'] ?? '', 'flag' => '', 'entity' => $dxcc['entity'] ?? 'Unknown' ]; - } // Apply continent filter early + } + // Apply continent filter early if ($filter_continent && (!property_exists($singlespot->dxcc_spotter, 'cont') || $de_lower != strtolower($singlespot->dxcc_spotter->cont ?? ''))) { continue; @@ -210,56 +214,56 @@ class Dxcluster_model extends CI_Model { $batch_statuses = $this->logbook_model->get_batch_spot_statuses( $spotsout, $logbooks_locations_array, - $band, - $mode + $band, + $mode + ); + + // Collect callsigns that need last_worked info (only those that are worked) + $worked_callsigns = []; + foreach ($spotsout as $spot) { + $callsign = $spot->spotted; + if (isset($batch_statuses[$callsign]) && $batch_statuses[$callsign]['worked_call']) { + $worked_callsigns[] = $callsign; + } + } + + // Batch fetch last_worked info for all worked callsigns + $last_worked_batch = []; + if (!empty($worked_callsigns)) { + $last_worked_batch = $this->logbook_model->get_batch_last_worked( + $worked_callsigns, + $logbooks_locations_array, + $band ); + } - // Collect callsigns that need last_worked info (only those that are worked) - $worked_callsigns = []; - foreach ($spotsout as $spot) { - $callsign = $spot->spotted; - if (isset($batch_statuses[$callsign]) && $batch_statuses[$callsign]['worked_call']) { - $worked_callsigns[] = $callsign; + // Map batch results back to spots + foreach ($spotsout as $index => $spot) { + $callsign = $spot->spotted; + if (isset($batch_statuses[$callsign])) { + $status = $batch_statuses[$callsign]; + $spot->worked_dxcc = $status['worked_dxcc']; + $spot->worked_call = $status['worked_call']; + $spot->cnfmd_dxcc = $status['cnfmd_dxcc']; + $spot->cnfmd_call = $status['cnfmd_call']; + $spot->cnfmd_continent = $status['cnfmd_continent']; + $spot->worked_continent = $status['worked_continent']; + + // Use batch last_worked data + if ($spot->worked_call && isset($last_worked_batch[$callsign])) { + $spot->last_wked = $last_worked_batch[$callsign]; + $spot->last_wked->LAST_QSO = date($custom_date_format, strtotime($spot->last_wked->LAST_QSO)); } + } else { + // Fallback for spots without status + $spot->worked_dxcc = false; + $spot->worked_call = false; + $spot->cnfmd_dxcc = false; + $spot->cnfmd_call = false; + $spot->cnfmd_continent = false; + $spot->worked_continent = false; } - // Batch fetch last_worked info for all worked callsigns - $last_worked_batch = []; - if (!empty($worked_callsigns)) { - $last_worked_batch = $this->logbook_model->get_batch_last_worked( - $worked_callsigns, - $logbooks_locations_array, - $band - ); - } - - // Map batch results back to spots - foreach ($spotsout as $index => $spot) { - $callsign = $spot->spotted; - if (isset($batch_statuses[$callsign])) { - $status = $batch_statuses[$callsign]; - $spot->worked_dxcc = $status['worked_dxcc']; - $spot->worked_call = $status['worked_call']; - $spot->cnfmd_dxcc = $status['cnfmd_dxcc']; - $spot->cnfmd_call = $status['cnfmd_call']; - $spot->cnfmd_continent = $status['cnfmd_continent']; - $spot->worked_continent = $status['worked_continent']; - - // Use batch last_worked data - if ($spot->worked_call && isset($last_worked_batch[$callsign])) { - $spot->last_wked = $last_worked_batch[$callsign]; - $spot->last_wked->LAST_QSO = date($custom_date_format, strtotime($spot->last_wked->LAST_QSO)); - } - } else { - // Fallback for spots without status - $spot->worked_dxcc = false; - $spot->worked_call = false; - $spot->cnfmd_dxcc = false; - $spot->cnfmd_call = false; - $spot->cnfmd_continent = false; - $spot->worked_continent = false; - } - $spotsout[$index] = $spot; } } @@ -269,7 +273,8 @@ class Dxcluster_model extends CI_Model { $this->cache->save($cache_key, $spotsout, 59); } - return $spotsout; } + return $spotsout; + } // We need to build functions that check the frequency limit // Right now this is just a proof of concept to determine mode diff --git a/application/models/Logbook_model.php b/application/models/Logbook_model.php index 311bdfdcf..bfc2d53dc 100644 --- a/application/models/Logbook_model.php +++ b/application/models/Logbook_model.php @@ -2693,79 +2693,6 @@ class Logbook_model extends CI_Model { return $query->num_rows(); } - /** - * Optimized function to check all spot statuses in a single database query - * Returns an array with worked/confirmed status for callsign, dxcc, and continent - * - * @param string $callsign Spotted callsign - * @param int $dxcc_id DXCC ID - * @param string $continent Continent code - * @param array $logbooks_locations_array Station IDs - * @param string $band Band (e.g., '20m', 'All') - * @param string $mode Mode (e.g., 'SSB', 'CW', 'All') - * @return array ['worked_call', 'worked_dxcc', 'worked_continent', 'cnfmd_call', 'cnfmd_dxcc', 'cnfmd_continent'] - */ - function get_spot_status($callsign, $dxcc_id, $continent, $logbooks_locations_array, $band = null, $mode = null) { - $user_default_confirmation = $this->session->userdata('user_default_confirmation'); - $qsl_where = $this->qsl_default_where($user_default_confirmation); - - // Build band filter with binding - $band_sql = ''; - $bind_params = []; - $band = ($band == 'All') ? null : $band; - if ($band != null && $band != 'SAT') { - $band_sql = " AND COL_BAND = ?"; - $bind_params[] = $band; - } else if ($band == 'SAT') { - $band_sql = " AND COL_SAT_NAME != ''"; - } - - // Build mode filter - $mode_sql = ''; - if (isset($mode) && $mode != 'All') { - $mode_sql = " AND COL_MODE IN " . $this->Modes->get_modes_from_qrgmode($mode, true); - } - - // Validate and prepare station IDs - $station_ids = implode(',', array_map('intval', $logbooks_locations_array)); - - // Add callsign, dxcc, and continent to bind params (each used twice in query) - $bind_params[] = $callsign; - $bind_params[] = $callsign; - $bind_params[] = $dxcc_id; - $bind_params[] = $dxcc_id; - $bind_params[] = $continent; - $bind_params[] = $continent; - - // Single optimized query to get all statuses - $sql = " - SELECT - MAX(CASE WHEN COL_CALL = ? THEN 1 ELSE 0 END) as worked_call, - MAX(CASE WHEN COL_CALL = ? AND ({$qsl_where}) THEN 1 ELSE 0 END) as cnfmd_call, - MAX(CASE WHEN COL_DXCC = ? THEN 1 ELSE 0 END) as worked_dxcc, - MAX(CASE WHEN COL_DXCC = ? AND ({$qsl_where}) THEN 1 ELSE 0 END) as cnfmd_dxcc, - MAX(CASE WHEN COL_CONT = ? THEN 1 ELSE 0 END) as worked_continent, - MAX(CASE WHEN COL_CONT = ? AND ({$qsl_where}) THEN 1 ELSE 0 END) as cnfmd_continent - FROM {$this->config->item('table_name')} - WHERE station_id IN ({$station_ids}) - {$band_sql} - {$mode_sql} - "; - - $query = $this->db->query($sql, $bind_params); - $result = $query->row_array(); - - // Convert to boolean - return [ - 'worked_call' => ($result['worked_call'] == 1), - 'worked_dxcc' => ($result['worked_dxcc'] == 1), - 'worked_continent' => ($result['worked_continent'] == 1), - 'cnfmd_call' => ($result['cnfmd_call'] == 1), - 'cnfmd_dxcc' => ($result['cnfmd_dxcc'] == 1), - 'cnfmd_continent' => ($result['cnfmd_continent'] == 1) - ]; - } - /** * Batch version - Get statuses for multiple spots in a single query * @@ -2785,11 +2712,11 @@ class Logbook_model extends CI_Model { // Build band filter with binding $band_sql = ''; - $bind_params = []; + $band_param = null; $band = ($band == 'All') ? null : $band; if ($band != null && $band != 'SAT') { $band_sql = " AND COL_BAND = ?"; - $bind_params[] = $band; + $band_param = $band; } else if ($band == 'SAT') { $band_sql = " AND COL_SAT_NAME != ''"; } @@ -2819,7 +2746,8 @@ class Logbook_model extends CI_Model { return []; } - $station_ids = implode(',', array_map('intval', $logbooks_locations_array)); + // Build placeholders for station IDs (fix SQL injection) + $station_ids_placeholders = implode(',', array_fill(0, count($logbooks_locations_array), '?')); // Build placeholders and bind parameters for IN clauses $callsigns_array = array_keys($callsigns); @@ -2830,28 +2758,68 @@ class Logbook_model extends CI_Model { $dxccs_placeholders = implode(',', array_fill(0, count($dxccs_array), '?')); $continents_placeholders = implode(',', array_fill(0, count($continents_array), '?')); - // Add values to bind params - $bind_params = array_merge($bind_params, $callsigns_array, $dxccs_array, $continents_array); + // Three separate queries for callsigns, DXCCs, and continents + // This is necessary because the OR logic doesn't work correctly with GROUP BY - // Single mega-query to get all statuses - $sql = " + // Query 1: Check callsigns + $sql_calls = " SELECT COL_CALL as callsign, + MAX(CASE WHEN ({$qsl_where}) THEN 1 ELSE 0 END) as is_confirmed + FROM {$this->config->item('table_name')} + WHERE station_id IN ({$station_ids_placeholders}) + AND COL_CALL IN ({$callsigns_placeholders}) + {$band_sql} + {$mode_sql} + GROUP BY COL_CALL + "; + + $bind_params_calls = array_merge($logbooks_locations_array, $callsigns_array); + if ($band_param !== null) { + $bind_params_calls[] = $band_param; + } + $query = $this->db->query($sql_calls, $bind_params_calls); + $results_calls = $query->result_array(); + + // Query 2: Check DXCCs + $sql_dxccs = " + SELECT COL_DXCC as dxcc, + MAX(CASE WHEN ({$qsl_where}) THEN 1 ELSE 0 END) as is_confirmed + FROM {$this->config->item('table_name')} + WHERE station_id IN ({$station_ids_placeholders}) + AND COL_DXCC IN ({$dxccs_placeholders}) + {$band_sql} + {$mode_sql} + GROUP BY COL_DXCC + "; + + $bind_params_dxccs = array_merge($logbooks_locations_array, $dxccs_array); + if ($band_param !== null) { + $bind_params_dxccs[] = $band_param; + } + $query = $this->db->query($sql_dxccs, $bind_params_dxccs); + $results_dxccs = $query->result_array(); + + // Query 3: Check continents + $sql_conts = " + SELECT COL_CONT as continent, MAX(CASE WHEN ({$qsl_where}) THEN 1 ELSE 0 END) as is_confirmed FROM {$this->config->item('table_name')} - WHERE station_id IN ({$station_ids}) - AND (COL_CALL IN ({$callsigns_placeholders}) - OR COL_DXCC IN ({$dxccs_placeholders}) - OR COL_CONT IN ({$continents_placeholders})) + WHERE station_id IN ({$station_ids_placeholders}) + AND COL_CONT IN ({$continents_placeholders}) {$band_sql} {$mode_sql} - GROUP BY COL_CALL, COL_DXCC, COL_CONT + GROUP BY COL_CONT "; - $query = $this->db->query($sql, $bind_params); - $results = $query->result_array(); + $bind_params_conts = array_merge($logbooks_locations_array, $continents_array); + if ($band_param !== null) { + $bind_params_conts[] = $band_param; + } + $query = $this->db->query($sql_conts, $bind_params_conts); + $results_conts = $query->result_array(); // Build lookup maps $worked_calls = []; @@ -2861,14 +2829,23 @@ class Logbook_model extends CI_Model { $worked_conts = []; $cnfmd_conts = []; - foreach ($results as $row) { + foreach ($results_calls as $row) { $worked_calls[$row['callsign']] = true; - $worked_dxccs[$row['dxcc']] = true; - $worked_conts[$row['continent']] = true; - if ($row['is_confirmed'] == 1) { $cnfmd_calls[$row['callsign']] = true; + } + } + + foreach ($results_dxccs as $row) { + $worked_dxccs[$row['dxcc']] = true; + if ($row['is_confirmed'] == 1) { $cnfmd_dxccs[$row['dxcc']] = true; + } + } + + foreach ($results_conts as $row) { + $worked_conts[$row['continent']] = true; + if ($row['is_confirmed'] == 1) { $cnfmd_conts[$row['continent']] = true; } } @@ -2913,42 +2890,66 @@ class Logbook_model extends CI_Model { // Build band filter with binding $band_sql = ''; - $bind_params = []; + $band_param = null; $band = ($band == 'All') ? null : $band; if ($band != null && $band != 'SAT') { $band_sql = " AND COL_BAND = ?"; - $bind_params[] = $band; + $band_param = $band; } else if ($band == 'SAT') { $band_sql = " AND COL_SAT_NAME != ''"; } - $station_ids = implode(',', array_map('intval', $logbooks_locations_array)); + // Build placeholders for station IDs (fix SQL injection) + $station_ids_placeholders = implode(',', array_fill(0, count($logbooks_locations_array), '?')); // Build placeholders for callsigns IN clause $callsigns_placeholders = implode(',', array_fill(0, count($callsigns), '?')); - // Add callsigns to bind params (used twice in query - once in subquery, once in main query) - $bind_params = array_merge($bind_params, $callsigns, $callsigns); - // Validate we have valid data - if (empty($station_ids) || empty($callsigns_placeholders)) { + if (empty($station_ids_placeholders) || empty($callsigns_placeholders)) { return []; } + // Build bind params: station_ids, callsigns for subquery, band (if set), station_ids again, callsigns for main query, band again (if set) + $bind_params = []; + + // First set: station_ids for subquery + $bind_params = array_merge($bind_params, $logbooks_locations_array); + + // Second: callsigns for subquery + $bind_params = array_merge($bind_params, $callsigns); + + // Third: band for subquery (if set) + if ($band_param !== null) { + $bind_params[] = $band_param; + } + + // Fourth set: station_ids for main query + $bind_params = array_merge($bind_params, $logbooks_locations_array); + + // Fifth: callsigns for main query + $bind_params = array_merge($bind_params, $callsigns); + + // Sixth: band for main query (if set) + if ($band_param !== null) { + $bind_params[] = $band_param; + } + // Query to get the most recent QSO for each callsign // Using a subquery to get max time per callsign, then join to get the full record + // Added LIMIT 1 in derived table to handle edge case of multiple QSOs at same timestamp $sql = " SELECT t1.COL_CALL, t1.COL_TIME_ON as LAST_QSO, t1.COL_MODE as LAST_MODE FROM {$this->config->item('table_name')} t1 INNER JOIN ( SELECT COL_CALL, MAX(COL_TIME_ON) as max_time FROM {$this->config->item('table_name')} - WHERE station_id IN ({$station_ids}) + WHERE station_id IN ({$station_ids_placeholders}) AND COL_CALL IN ({$callsigns_placeholders}) {$band_sql} GROUP BY COL_CALL ) t2 ON t1.COL_CALL = t2.COL_CALL AND t1.COL_TIME_ON = t2.max_time - WHERE t1.station_id IN ({$station_ids}) + WHERE t1.station_id IN ({$station_ids_placeholders}) AND t1.COL_CALL IN ({$callsigns_placeholders}) {$band_sql} "; @@ -2957,9 +2958,12 @@ class Logbook_model extends CI_Model { $results = $query->result(); // Build lookup map keyed by callsign + // If multiple QSOs have same timestamp, keep first one returned $last_worked = []; foreach ($results as $row) { - $last_worked[$row->COL_CALL] = $row; + if (!isset($last_worked[$row->COL_CALL])) { + $last_worked[$row->COL_CALL] = $row; + } } return $last_worked;