diff --git a/application/controllers/Backup.php b/application/controllers/Backup.php
index 60aac213d..953b2db01 100644
--- a/application/controllers/Backup.php
+++ b/application/controllers/Backup.php
@@ -5,12 +5,244 @@ class Backup extends CI_Controller {
{
parent::__construct();
}
+
+ /* ===== User-level JSON+ZIP backup (Stations, Logbooks, QSOs) ===== */
+ public function user_export() {
+ $this->load->model('user_model');
+ if ($this->user_model->validate_session() == 0) { redirect('user/login'); }
+ if(!$this->user_model->authorize(2)) { $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); redirect('dashboard'); }
+
+ ini_set('memory_limit', '512M');
+ $this->load->model('Stations');
+ $this->load->model('Logbook_model');
+ $this->load->dbutil();
+
+ $tmp_json = tempnam(sys_get_temp_dir(), 'cloudlog_backup_') . '.json';
+ $tmp_zip = tempnam(sys_get_temp_dir(), 'cloudlog_backup_') . '.zip';
+
+ try {
+ $user_id = $this->session->userdata('user_id');
+ if (!$user_id) { show_error('User not authenticated.', 401); return; }
+
+ while (ob_get_level()) { ob_end_clean(); }
+
+ $result = array();
+
+ // Stations for user
+ $this->db->where('user_id', $user_id);
+ $stations = $this->db->get('station_profile')->result_array();
+ $result['stations'] = $stations;
+
+ // QSOs per station
+ $station_qsos = array();
+ foreach ($stations as $station) {
+ if (!isset($station['station_id'])) continue;
+ $station_id = $station['station_id'];
+ $qso_chunk_size = 10000; $qso_offset = 0; $qsos = array();
+ do {
+ $this->db->where('station_id', $station_id);
+ $this->db->limit($qso_chunk_size, $qso_offset);
+ $chunk = $this->db->get($this->config->item('table_name'))->result_array();
+ $qsos = array_merge($qsos, $chunk); $qso_offset += $qso_chunk_size;
+ } while (count($chunk) === $qso_chunk_size);
+ $station_qsos[$station_id] = $qsos;
+ }
+ $result['station_qsos'] = $station_qsos;
+
+ // Logbooks for user (include QSOs snapshot for backwards compatibility)
+ $logbooks = array();
+ $this->db->where('user_id', $user_id);
+ foreach ($this->db->get('station_logbooks')->result_array() as $logbook) {
+ $station_id = isset($logbook['station_id']) ? $logbook['station_id'] : null;
+ $qso_chunk_size = 10000; $qso_offset = 0; $qsos = array();
+ if ($station_id) {
+ do {
+ $this->db->where('station_id', $station_id);
+ $this->db->limit($qso_chunk_size, $qso_offset);
+ $chunk = $this->db->get($this->config->item('table_name'))->result_array();
+ $qsos = array_merge($qsos, $chunk); $qso_offset += $qso_chunk_size;
+ } while (count($chunk) === $qso_chunk_size);
+ }
+ $logbook['qsos'] = $qsos; $logbooks[] = $logbook;
+ }
+ $result['logbooks'] = $logbooks;
+
+ // Relationships if present
+ $tables = $this->db->list_tables();
+ if (in_array('station_logbooks_entity', $tables)) {
+ $this->db->where('user_id', $user_id);
+ $result['logbooks_entity'] = $this->db->get('station_logbooks_entity')->result_array();
+ } else { $result['logbooks_entity'] = []; }
+
+ $result['schema_version'] = '1.0';
+ $result['exported_at'] = date('c');
+
+ file_put_contents($tmp_json, json_encode($result, JSON_PRETTY_PRINT));
+ $zip = new ZipArchive();
+ if ($zip->open($tmp_zip, ZipArchive::CREATE) !== TRUE) { throw new Exception('Could not create ZIP file'); }
+ $zip->addFile($tmp_json, 'cloudlog_backup.json');
+ $zip->close();
+
+ header('Content-Type: application/zip');
+ header('Content-Disposition: attachment; filename="cloudlog_backup_'.date('Ymd_His').'.zip"');
+ header('Content-Length: ' . filesize($tmp_zip));
+ header('Cache-Control: no-store, no-cache');
+ readfile($tmp_zip);
+ @unlink($tmp_json); @unlink($tmp_zip); exit;
+ } catch (Exception $e) { log_message('error', 'User export error: '.$e->getMessage()); show_error('Export failed: '.$e->getMessage(), 500); }
+ }
+
+ public function user_import() {
+ $this->load->model('user_model');
+ if ($this->user_model->validate_session() == 0) { redirect('user/login'); }
+ if(!$this->user_model->authorize(2)) { $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); redirect('dashboard'); }
+
+ ini_set('memory_limit', '512M');
+ $this->load->model('Stations');
+ $this->load->model('Logbook_model');
+ $this->load->library('session');
+
+ if (!isset($_FILES['backup_file']) || $_FILES['backup_file']['error'] !== UPLOAD_ERR_OK) { $this->session->set_flashdata('notice', 'No file uploaded or upload error.'); redirect('backup'); return; }
+
+ $uploaded_file = $_FILES['backup_file']['tmp_name'];
+ $temp_json = tempnam(sys_get_temp_dir(), 'cloudlog_import_') . '.json';
+
+ $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($finfo, $uploaded_file); finfo_close($finfo);
+ if ($mime_type === 'application/zip') {
+ $zip = new ZipArchive();
+ if ($zip->open($uploaded_file) === TRUE) {
+ $json_content = $zip->getFromName('cloudlog_backup.json'); $zip->close();
+ if ($json_content === false) { $this->session->set_flashdata('notice', 'Invalid backup file: JSON not found in ZIP.'); redirect('backup'); return; }
+ file_put_contents($temp_json, $json_content); unset($json_content);
+ } else { $this->session->set_flashdata('notice', 'Could not open ZIP file.'); redirect('backup'); return; }
+ } else { copy($uploaded_file, $temp_json); }
+
+ $json = file_get_contents($temp_json); $data = json_decode($json, true); unset($json);
+ if (!$data || !isset($data['stations']) || !isset($data['logbooks'])) { @unlink($temp_json); $this->session->set_flashdata('notice', 'Invalid backup file.'); redirect('backup'); return; }
+
+ $station_qsos = isset($data['station_qsos']) ? $data['station_qsos'] : array();
+ $import_preview = array(
+ 'stations' => array_map(function($station) use ($station_qsos) {
+ $station_id = isset($station['station_id']) ? $station['station_id'] : null;
+ $qso_count = ($station_id && isset($station_qsos[$station_id])) ? count($station_qsos[$station_id]) : 0;
+ return array(
+ 'station_id' => $station_id,
+ 'station_callsign' => $station['station_callsign'],
+ 'station_profile_name' => $station['station_profile_name'],
+ 'qsos_count' => $qso_count,
+ );
+ }, $data['stations']),
+ 'logbooks' => array_map(function($logbook) {
+ $qso_count = isset($logbook['qsos']) ? count($logbook['qsos']) : 0;
+ return array(
+ 'logbook_id' => $logbook['logbook_id'],
+ 'logbook_name' => $logbook['logbook_name'],
+ 'station_id' => isset($logbook['station_id']) ? $logbook['station_id'] : null,
+ 'qsos_count' => $qso_count,
+ );
+ }, $data['logbooks']),
+ );
+
+ $this->session->set_userdata('import_backup_file', $temp_json);
+ // HTMX partial
+ $this->load->view('backup/import_preview', $import_preview);
+ }
+
+ public function user_do_import() {
+ $this->load->model('user_model');
+ if ($this->user_model->validate_session() == 0) { redirect('user/login'); }
+ if(!$this->user_model->authorize(2)) { echo '
Not authorized
'; return; }
+
+ ini_set('memory_limit', '512M');
+ $this->load->model('Stations');
+ $this->load->model('Logbook_model');
+ $this->load->library('session');
+
+ $temp_file = $this->session->userdata('import_backup_file');
+ if (!$temp_file || !file_exists($temp_file)) { echo 'No import data found.
'; return; }
+ $json = file_get_contents($temp_file); $data = json_decode($json, true); unset($json);
+ if (!$data) { echo 'Failed to parse backup data.
'; @unlink($temp_file); $this->session->unset_userdata('import_backup_file'); return; }
+
+ $import_stations = $this->input->post('import_stations');
+ $import_logbooks = $this->input->post('import_logbooks');
+ if (!is_array($import_stations)) $import_stations = array();
+ if (!is_array($import_logbooks)) $import_logbooks = array();
+ if (empty($import_stations) && empty($import_logbooks)) { echo 'Please select at least one station or logbook to import.
'; return; }
+
+ $current_user_id = $this->session->userdata('user_id');
+ if (!$current_user_id) { echo 'User not authenticated.
'; return; }
+
+ $total_stations = count($import_stations); $total_logbooks = count($import_logbooks); $total_qsos = 0;
+ foreach ($data['logbooks'] as $logbook) { if (in_array($logbook['logbook_id'], $import_logbooks) && isset($logbook['qsos'])) { $total_qsos += count($logbook['qsos']); } }
+ if (isset($data['station_qsos'])) { foreach ($data['station_qsos'] as $sid => $qsosArr) { if (in_array($sid, $import_stations)) { $total_qsos += count($qsosArr); } } }
+
+ $imported = array('stations'=>0,'logbooks'=>0,'qsos'=>0,'conflicts'=>array(),'step'=>'','total_stations'=>$total_stations,'total_logbooks'=>$total_logbooks,'total_qsos'=>$total_qsos);
+ $station_id_map = array();
+
+ // Stations
+ $imported['step'] = 'Importing stations';
+ foreach ($data['stations'] as $station) {
+ if (!in_array($station['station_id'], $import_stations)) continue; $old_station_id = $station['station_id'];
+ $this->db->where('station_callsign', $station['station_callsign']);
+ $this->db->where('station_profile_name', $station['station_profile_name']);
+ $this->db->where('user_id', $current_user_id);
+ $conflict = $this->db->get('station_profile')->row_array();
+ if ($conflict) { $imported['conflicts'][] = 'Station exists (reusing): '.$station['station_callsign'].' - '.$station['station_profile_name'].' (ID '.$conflict['station_id'].')'; $station_id_map[$old_station_id] = $conflict['station_id']; continue; }
+ unset($station['station_id']); $station['user_id'] = $current_user_id; $station['station_active'] = 0; // never active on import
+ $this->db->insert('station_profile', $station); $new_station_id = $this->db->insert_id(); if (!$new_station_id) { $imported['conflicts'][] = 'Failed to create station: '.$station['station_callsign'].' - '.$station['station_profile_name']; continue; }
+ $station_id_map[$old_station_id] = $new_station_id; $imported['stations']++;
+ }
+
+ // Logbooks
+ $imported['step'] = 'Importing logbooks';
+ foreach ($data['logbooks'] as $logbook) {
+ if (!in_array($logbook['logbook_id'], $import_logbooks)) continue;
+ $this->db->where('logbook_name', $logbook['logbook_name']); $this->db->where('user_id', $current_user_id); $conflict = $this->db->get('station_logbooks')->row_array();
+ if ($conflict) { $imported['conflicts'][] = 'Logbook exists (skipping): '.$logbook['logbook_name']; continue; }
+ $logbook_copy = $logbook; unset($logbook_copy['qsos']); unset($logbook_copy['logbook_id']); if (isset($logbook_copy['active_logbook'])) unset($logbook_copy['active_logbook']); $logbook_copy['user_id'] = $current_user_id; if (isset($logbook_copy['station_id']) && isset($station_id_map[$logbook_copy['station_id']])) { $logbook_copy['station_id'] = $station_id_map[$logbook_copy['station_id']]; }
+ $this->db->insert('station_logbooks', $logbook_copy); $imported['logbooks']++;
+ }
+
+ // QSOs from station_qsos
+ $imported['step'] = 'Importing QSOs';
+ if (isset($data['station_qsos'])) {
+ foreach ($data['station_qsos'] as $old_station_id => $qsos) {
+ if (!in_array($old_station_id, $import_stations)) continue; if (!isset($station_id_map[$old_station_id])) continue; $new_station_id = $station_id_map[$old_station_id];
+ foreach (array_chunk($qsos, 1000) as $qso_chunk) {
+ foreach ($qso_chunk as $qso) {
+ unset($qso['COL_PRIMARY_KEY']); $qso['station_id'] = $new_station_id;
+ $this->db->where('COL_TIME_ON', $qso['COL_TIME_ON']); $this->db->where('COL_CALL', $qso['COL_CALL']); $this->db->where('station_id', $qso['station_id']);
+ $conflict = $this->db->get($this->config->item('table_name'))->row_array(); if ($conflict) { $imported['conflicts'][] = 'QSO conflict: '.$qso['COL_CALL'].' @ '.$qso['COL_TIME_ON']; continue; }
+ $this->db->insert($this->config->item('table_name'), $qso); $imported['qsos']++;
+ }
+ }
+ }
+ }
+ // QSOs from logbooks (legacy)
+ foreach ($data['logbooks'] as $logbook) {
+ if (!in_array($logbook['logbook_id'], $import_logbooks)) continue; if (!isset($logbook['qsos'])) continue;
+ foreach (array_chunk($logbook['qsos'], 1000) as $qso_chunk) {
+ foreach ($qso_chunk as $qso) {
+ unset($qso['COL_PRIMARY_KEY']); if (isset($qso['station_id']) && isset($station_id_map[$qso['station_id']])) { $qso['station_id'] = $station_id_map[$qso['station_id']]; }
+ $this->db->where('COL_TIME_ON', $qso['COL_TIME_ON']); $this->db->where('COL_CALL', $qso['COL_CALL']); $this->db->where('station_id', $qso['station_id']);
+ $conflict = $this->db->get($this->config->item('table_name'))->row_array(); if ($conflict) { $imported['conflicts'][] = 'QSO conflict: '.$qso['COL_CALL'].' @ '.$qso['COL_TIME_ON']; continue; }
+ $this->db->insert($this->config->item('table_name'), $qso); $imported['qsos']++;
+ }
+ }
+ }
+
+ $temp_file = $this->session->userdata('import_backup_file'); if ($temp_file && file_exists($temp_file)) { @unlink($temp_file); }
+ $this->session->unset_userdata('import_backup_file');
+
+ $this->load->view('backup/import_progress', $imported);
+ }
/* User Facing Links to Backup URLs */
public function index()
{
$this->load->model('user_model');
- if(!$this->user_model->authorize(99)) { $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); redirect('dashboard'); }
+ // Allow all authenticated operators (level 2+) instead of only admins
+ if(!$this->user_model->authorize(2)) { $this->session->set_flashdata('notice', 'You\'re not allowed to do that!'); redirect('dashboard'); }
$data['page_title'] = "Backup";
diff --git a/application/views/adminbackup/import_preview.php b/application/views/adminbackup/import_preview.php
new file mode 100644
index 000000000..883c61fb5
--- /dev/null
+++ b/application/views/adminbackup/import_preview.php
@@ -0,0 +1,44 @@
+
+
Import Preview
+
+
+
+
+
Importing, please wait...
+
+
+
+
+
+
diff --git a/application/views/adminbackup/index.php b/application/views/adminbackup/index.php
new file mode 100644
index 000000000..05e004849
--- /dev/null
+++ b/application/views/adminbackup/index.php
@@ -0,0 +1,18 @@
+
+
Admin Backup & Restore
+
+
+
Backup
+
+
+
+
Restore
+
+
+
+
diff --git a/application/views/backup/import_preview.php b/application/views/backup/import_preview.php
new file mode 100644
index 000000000..f7019eeef
--- /dev/null
+++ b/application/views/backup/import_preview.php
@@ -0,0 +1,48 @@
+
+
Import Preview
+
+
+
+
+
Importing, please wait...
+
+
+
+
+
+
\ No newline at end of file
diff --git a/application/views/backup/import_progress.php b/application/views/backup/import_progress.php
new file mode 100644
index 000000000..fb86888a2
--- /dev/null
+++ b/application/views/backup/import_progress.php
@@ -0,0 +1,21 @@
+
+
Import Progress
+
+ - Stations imported: /
+ - Logbooks imported: /
+ - QSOs imported: /
+
+
+
Current step:
+
+
+
+
Conflicts / Notices:
+
+
+
+
\ No newline at end of file
diff --git a/application/views/backup/main.php b/application/views/backup/main.php
index d9afabede..de373c3ad 100644
--- a/application/views/backup/main.php
+++ b/application/views/backup/main.php
@@ -9,16 +9,23 @@
-
-
-
-
Some of the data stored in Cloudlog can be exported so that you can keep a backup copy elsewhere.
-
It's recommended to create backups on a regular basis to protect your data.
-
Backup ADIF data
-
Backup Notes
-
+
+
+
+
+
+
Export and restore only your own Stations, Logbooks and QSOs. Imported stations/logbooks are never set active automatically; duplicates reuse existing records; QSOs skip conflicts.
+
+
+