From dae30161ae7777feaf162c1ae2caf576a8e8d598 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 16:53:08 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20multi-remote=20storage=20=E2=80=94=20mu?= =?UTF-8?q?ltiple=20destinations=20per=20profile=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New #__mokosuitebackup_remotes table stores remote destinations with JSON params per type (SFTP/S3/GDrive/FTP). Each profile can have multiple enabled destinations — the engine uploads to all of them. Database: - New table with profile_id FK, type, enabled, params JSON, ordering - Migration auto-converts existing profile remote columns to new table - RemoteTable, RemoteModel, RemotesModel classes Engine: - BackupEngine: loadRemoteDestinations() + createUploaderFromParams() iterates all enabled remotes, falls back to legacy columns - SteppedBackupEngine: one upload step per remote destination, persisted via session.remoteDestinations + remoteIndex - Local copy only deleted when ALL uploads succeed UI: - Profile edit: "Remote Destinations" linked table with AJAX CRUD - Add/edit modal with type selector showing dynamic fields - Toggle enabled/disabled, delete with confirmation - Legacy fields hidden when remotes configured, shown as fallback - Secrets masked in responses, merged from DB on save Closes #97 --- CHANGELOG.md | 8 + .../com_mokosuitebackup/forms/profile.xml | 7 + .../language/en-GB/com_mokosuitebackup.ini | 9 + .../com_mokosuitebackup/sql/install.mysql.sql | 16 + .../sql/uninstall.mysql.sql | 1 + .../sql/updates/mysql/01.41.00.sql | 97 +++ .../src/Controller/AjaxController.php | 329 +++++++++++ .../src/Engine/BackupEngine.php | 155 +++-- .../src/Engine/SteppedBackupEngine.php | 245 ++++++-- .../src/Engine/SteppedSession.php | 4 + .../src/Model/RemoteModel.php | 67 +++ .../src/Model/RemotesModel.php | 88 +++ .../src/Table/RemoteTable.php | 94 +++ .../com_mokosuitebackup/tmpl/profile/edit.php | 550 +++++++++++++++++- 14 files changed, 1581 insertions(+), 89 deletions(-) create mode 100644 source/packages/com_mokosuitebackup/sql/updates/mysql/01.41.00.sql create mode 100644 source/packages/com_mokosuitebackup/src/Model/RemoteModel.php create mode 100644 source/packages/com_mokosuitebackup/src/Model/RemotesModel.php create mode 100644 source/packages/com_mokosuitebackup/src/Table/RemoteTable.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb7b92..8399bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added +- Multi-remote storage: new `#__mokosuitebackup_remotes` table for multiple destinations per profile (#97) +- Remote destinations UI: AJAX-driven add/edit/delete/toggle modal on profile edit view +- Engine integration: BackupEngine and SteppedBackupEngine upload to all enabled destinations +- Migration SQL: auto-migrates existing SFTP/S3/GDrive/FTP configs to new table +- Backward compatibility: falls back to legacy single-remote columns if remotes table is empty +- Secrets masked in API responses, merged from DB on save to prevent leakage + ## [01.40.00] --- 2026-06-23 diff --git a/source/packages/com_mokosuitebackup/forms/profile.xml b/source/packages/com_mokosuitebackup/forms/profile.xml index c0b05b5..661673d 100644 --- a/source/packages/com_mokosuitebackup/forms/profile.xml +++ b/source/packages/com_mokosuitebackup/forms/profile.xml @@ -202,6 +202,13 @@
+ sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $profileId = $this->input->getInt('profile_id', 0); + + if (!$profileId) { + $this->sendJson(['error' => true, 'message' => 'Missing profile_id']); + + return; + } + + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('profile_id') . ' = ' . $profileId) + ->order($db->quoteName('ordering') . ' ASC, ' . $db->quoteName('id') . ' ASC'); + $db->setQuery($query); + $rows = $db->loadObjectList(); + } catch (\Exception $e) { + $this->sendJson(['error' => true, 'message' => 'Database error'], 500); + + return; + } + + // Decode JSON config and mask secrets + $items = []; + + foreach ($rows as $row) { + $config = json_decode($row->config, true) ?: []; + + // Mask sensitive fields so they never leave the server in list views + $masked = $this->maskSecrets($config, $row->type); + + $items[] = [ + 'id' => (int) $row->id, + 'profile_id' => (int) $row->profile_id, + 'title' => $row->title, + 'type' => $row->type, + 'enabled' => (int) $row->enabled, + 'keep_local' => (int) $row->keep_local, + 'config' => $masked, + 'ordering' => (int) $row->ordering, + ]; + } + + $this->sendJson(['error' => false, 'items' => $items]); + } + + /** + * Save (create or update) a remote destination. + * POST: task=ajax.saveRemote (JSON body or form fields) + */ + public function saveRemote(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $id = $this->input->getInt('remote_id', 0); + $profileId = $this->input->getInt('profile_id', 0); + $title = trim($this->input->getString('remote_title', '')); + $type = $this->input->getCmd('remote_type', 'sftp'); + $enabled = $this->input->getInt('remote_enabled', 1); + $keepLocal = $this->input->getInt('remote_keep_local', 1); + $configRaw = $this->input->getString('remote_config', '{}'); + + if (!$profileId) { + $this->sendJson(['error' => true, 'message' => 'Missing profile_id']); + + return; + } + + if (empty($title)) { + $this->sendJson(['error' => true, 'message' => 'Title is required']); + + return; + } + + $config = json_decode($configRaw, true); + + if (!is_array($config)) { + $this->sendJson(['error' => true, 'message' => 'Invalid config JSON']); + + return; + } + + // If editing, merge secrets that were masked with __KEEP_EXISTING__ + if ($id) { + $config = $this->mergeExistingSecrets($id, $config, $type); + } + + $db = Factory::getDbo(); + + try { + $table = new \Joomla\Component\MokoSuiteBackup\Administrator\Table\RemoteTable($db); + + if ($id) { + $table->load($id); + + // Verify ownership + if ((int) $table->profile_id !== $profileId) { + $this->sendJson(['error' => true, 'message' => 'Remote does not belong to this profile'], 403); + + return; + } + } + + $table->profile_id = $profileId; + $table->title = $title; + $table->type = $type; + $table->enabled = $enabled ? 1 : 0; + $table->keep_local = $keepLocal ? 1 : 0; + $table->config = json_encode($config); + + if (!$table->check() || !$table->store()) { + $this->sendJson(['error' => true, 'message' => $table->getError() ?: 'Save failed']); + + return; + } + + $this->sendJson(['error' => false, 'id' => (int) $table->id, 'message' => 'Saved']); + } catch (\Exception $e) { + $this->sendJson(['error' => true, 'message' => 'Database error: ' . $e->getMessage()], 500); + } + } + + /** + * Delete a remote destination. + * POST: task=ajax.deleteRemote&remote_id=1&profile_id=1 + */ + public function deleteRemote(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $id = $this->input->getInt('remote_id', 0); + $profileId = $this->input->getInt('profile_id', 0); + + if (!$id || !$profileId) { + $this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']); + + return; + } + + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('id') . ' = ' . $id) + ->where($db->quoteName('profile_id') . ' = ' . $profileId); + $db->setQuery($query); + $db->execute(); + + $this->sendJson(['error' => false, 'message' => 'Deleted']); + } catch (\Exception $e) { + $this->sendJson(['error' => true, 'message' => 'Database error'], 500); + } + } + + /** + * Toggle enabled/disabled for a remote destination. + * POST: task=ajax.toggleRemote&remote_id=1&profile_id=1 + */ + public function toggleRemote(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $id = $this->input->getInt('remote_id', 0); + $profileId = $this->input->getInt('profile_id', 0); + + if (!$id || !$profileId) { + $this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']); + + return; + } + + try { + $db = Factory::getDbo(); + + // Load current state + $query = $db->getQuery(true) + ->select($db->quoteName('enabled')) + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('id') . ' = ' . $id) + ->where($db->quoteName('profile_id') . ' = ' . $profileId); + $db->setQuery($query); + $current = $db->loadResult(); + + if ($current === null) { + $this->sendJson(['error' => true, 'message' => 'Remote not found'], 404); + + return; + } + + $newState = $current ? 0 : 1; + + $update = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitebackup_remotes')) + ->set($db->quoteName('enabled') . ' = ' . $newState) + ->set($db->quoteName('modified') . ' = ' . $db->quote(date('Y-m-d H:i:s'))) + ->where($db->quoteName('id') . ' = ' . $id) + ->where($db->quoteName('profile_id') . ' = ' . $profileId); + $db->setQuery($update); + $db->execute(); + + $this->sendJson(['error' => false, 'enabled' => $newState]); + } catch (\Exception $e) { + $this->sendJson(['error' => true, 'message' => 'Database error'], 500); + } + } + + /** + * Mask sensitive values in a remote config array for display. + */ + private function maskSecrets(array $config, string $type): array + { + $secrets = [ + 'sftp' => ['password', 'passphrase', 'key_data'], + 's3' => ['secret_key'], + 'google_drive' => ['client_secret', 'refresh_token'], + ]; + + $fields = $secrets[$type] ?? []; + + foreach ($fields as $field) { + if (!empty($config[$field])) { + $config[$field] = '********'; + } + } + + return $config; + } + + /** + * When updating a remote, merge back secrets that were masked in the form. + */ + private function mergeExistingSecrets(int $id, array $config, string $type): array + { + $secrets = [ + 'sftp' => ['password', 'passphrase', 'key_data'], + 's3' => ['secret_key'], + 'google_drive' => ['client_secret', 'refresh_token'], + ]; + + $fields = $secrets[$type] ?? []; + $needsMerge = false; + + foreach ($fields as $field) { + if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) { + $needsMerge = true; + + break; + } + } + + if (!$needsMerge) { + return $config; + } + + // Load existing config from DB + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('config')) + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $existing = json_decode($db->loadResult() ?: '{}', true) ?: []; + } catch (\Exception $e) { + return $config; + } + + foreach ($fields as $field) { + if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) { + $config[$field] = $existing[$field] ?? ''; + } + } + + return $config; + } + /** * Browse directories on a remote SFTP server for the path picker. * POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index d69829f..52f0a58 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -288,47 +288,81 @@ class BackupEngine $remoteFilename = ''; $uploadFailed = false; - // Step 3: Remote upload (if configured) - // Wrapped in its own try-catch so a remote failure does not mark - // the entire backup as failed — the local archive is preserved. - $remoteStorage = $profile->remote_storage ?? 'none'; + /* Step 3: Remote upload — iterate all enabled destinations */ + $remotes = $this->loadRemoteDestinations($db, $profileId); - if ($remoteStorage !== 'none') { - try { - $this->log('Starting remote upload (' . $remoteStorage . ')...'); - $uploader = $this->createUploader($remoteStorage, $profile); - $uploadResult = $uploader->upload($archivePath, $archiveName); + if (!empty($remotes)) { + foreach ($remotes as $remote) { + try { + $this->log('Uploading to: ' . $remote->title . ' (' . $remote->type . ')...'); + $params = json_decode($remote->params, true) ?: []; + $uploader = $this->createUploaderFromParams($remote->type, $params); + $result = $uploader->upload($archivePath, $archiveName); - if ($uploadResult['success']) { - $remoteFilename = $uploadResult['remote_path'] ?? $archiveName; - $this->log('Remote upload complete: ' . $uploadResult['message']); + if ($result['success']) { + $remoteFilename = $result['remote_path'] ?? $archiveName; + $this->log(' Upload complete: ' . $result['message']); - // Upload standalone restore.php alongside the backup if in standalone mode - if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) { - $this->log('Uploading standalone restore.php...'); - $restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php'); - - if ($restoreUpload['success']) { - $this->log('Standalone restore.php uploaded'); - } else { - $this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']); + /* Upload standalone restore.php if in standalone mode */ + if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) { + $uploader->upload($restoreScriptPath, 'restore.php'); } + } else { + $uploadFailed = true; + $this->log(' WARNING: Upload failed: ' . $result['message']); } - - // Delete local copy if configured - if (empty($profile->remote_keep_local) && is_file($archivePath)) { - @unlink($archivePath); - $this->log('Local copy removed (remote_keep_local = off)'); - } - } else { + } catch (\Throwable $e) { $uploadFailed = true; - $this->log('WARNING: Remote upload failed: ' . $uploadResult['message']); + $this->log(' WARNING: Upload exception: ' . $e->getMessage()); + } + } + + /* Delete local copy only when ALL remotes succeeded and profile says so */ + if (!$uploadFailed && empty($profile->remote_keep_local) && is_file($archivePath)) { + @unlink($archivePath); + $this->log('Local copy removed (remote_keep_local = off)'); + } + } else { + /* Backward-compat: fall back to legacy single-remote column */ + $remoteStorage = $profile->remote_storage ?? 'none'; + + if ($remoteStorage !== 'none') { + try { + $this->log('Starting remote upload (' . $remoteStorage . ')...'); + $uploader = $this->createUploader($remoteStorage, $profile); + $uploadResult = $uploader->upload($archivePath, $archiveName); + + if ($uploadResult['success']) { + $remoteFilename = $uploadResult['remote_path'] ?? $archiveName; + $this->log('Remote upload complete: ' . $uploadResult['message']); + + // Upload standalone restore.php alongside the backup if in standalone mode + if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) { + $this->log('Uploading standalone restore.php...'); + $restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php'); + + if ($restoreUpload['success']) { + $this->log('Standalone restore.php uploaded'); + } else { + $this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']); + } + } + + // Delete local copy if configured + if (empty($profile->remote_keep_local) && is_file($archivePath)) { + @unlink($archivePath); + $this->log('Local copy removed (remote_keep_local = off)'); + } + } else { + $uploadFailed = true; + $this->log('WARNING: Remote upload failed: ' . $uploadResult['message']); + $this->log('Local backup is preserved.'); + } + } catch (\Throwable $e) { + $uploadFailed = true; + $this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage()); $this->log('Local backup is preserved.'); } - } catch (\Throwable $e) { - $uploadFailed = true; - $this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage()); - $this->log('Local backup is preserved.'); } } @@ -487,6 +521,8 @@ class BackupEngine /** * Create the appropriate remote uploader based on the storage type. + * Legacy method — used by backward-compat fallback when remotes table + * does not exist. */ private function createUploader(string $type, object $profile): RemoteUploaderInterface { @@ -499,6 +535,59 @@ class BackupEngine }; } + /** + * Create a remote uploader from JSON params (multi-remote destinations). + * + * Builds a fake profile-like object from the params array so the existing + * uploader constructors work without modification. + * + * @param string $type Remote type: ftp, sftp, s3, google_drive + * @param array $params Key-value params decoded from the remote's JSON + * + * @return RemoteUploaderInterface + */ + private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface + { + $fake = (object) $params; + + return match ($type) { + 'ftp' => new FtpUploader($fake), + 'sftp' => new SftpUploader($fake), + 'google_drive' => new GoogleDriveUploader($fake), + 's3' => new S3Uploader($fake), + default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), + }; + } + + /** + * Load enabled remote destinations for a profile from the remotes table. + * + * Returns an empty array when the table does not exist (pre-migration) + * so the caller can fall back to the legacy single-remote column. + * + * @param object $db Database driver + * @param int $profileId Profile ID + * + * @return object[] Array of remote destination rows + */ + private function loadRemoteDestinations(object $db, int $profileId): array + { + try { + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profileId) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } catch (\Throwable $e) { + // Table does not exist yet (pre-migration) — fall back to legacy + return []; + } + } + /** * Load the file manifest from the most recent full backup for this profile. * Used by differential backups to determine which files changed. diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index 990fea3..c808882 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -73,6 +73,10 @@ class SteppedBackupEngine $session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); $session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); + // Load multi-remote destinations from the remotes table + $session->remoteDestinations = $this->loadRemoteDestinations($db, $profileId); + $session->remoteIndex = 0; + // Resolve placeholders in directory and filename $resolver = new PlaceholderResolver($profile); $backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir)); @@ -147,13 +151,22 @@ class SteppedBackupEngine } $totalSteps += 1; // finalize step - $totalSteps += ($session->remoteStorage !== 'none') ? 1 : 0; // upload step + + // Determine upload step count: one step per remote destination, + // or one step for legacy single-remote, or zero if no remotes. + $remoteCount = count($session->remoteDestinations); + + if ($remoteCount > 0) { + $totalSteps += $remoteCount; + } elseif ($session->remoteStorage !== 'none') { + $totalSteps += 1; + } $session->totalSteps = $totalSteps; $session->currentStep = 1; $session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files'; $session->log('Backup initialized: ' . $session->description); - $session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')'); + $session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ', remotes: ' . $remoteCount . ')'); // Log any preflight warnings into the session foreach ($preflightResult['warnings'] as $warning) { $session->log('PREFLIGHT WARNING: ' . $warning); @@ -391,7 +404,17 @@ class SteppedBackupEngine $db->updateObject('#__mokosuitebackup_records', $update, 'id'); $session->currentStep++; - $session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete'; + + // Determine next phase: multi-remote, legacy single-remote, or complete + $hasMultiRemote = !empty($session->remoteDestinations); + $hasLegacyRemote = $session->remoteStorage !== 'none'; + + if ($hasMultiRemote || $hasLegacyRemote) { + $session->phase = 'upload'; + } else { + $session->phase = 'complete'; + } + $session->statusMessage = 'Archive finalized: ' . $sizeHuman; $session->log('Archive finalized: ' . $sizeHuman); @@ -402,6 +425,10 @@ class SteppedBackupEngine /** * Upload phase: send archive to remote storage. + * + * When multi-remote destinations are configured, each call uploads to + * one destination (one step per remote). When only the legacy + * single-remote column is set, uploads in a single step. */ private function stepUpload(SteppedSession $session): void { @@ -409,62 +436,126 @@ class SteppedBackupEngine $remoteFilename = ''; $uploadFailed = false; - // Wrapped in its own try-catch so a remote failure does not mark - // the entire backup as failed — the local archive is preserved. - try { - // Reload profile for remote settings - $query = $db->getQuery(true) - ->select('*') - ->from($db->quoteName('#__mokosuitebackup_profiles')) - ->where($db->quoteName('id') . ' = ' . $session->profileId); - $db->setQuery($query); - $profile = $db->loadObject(); + if (!empty($session->remoteDestinations)) { + // ── Multi-remote path ────────────────────────────────── + $index = $session->remoteIndex; - $uploader = match ($session->remoteStorage) { - 'ftp' => new FtpUploader($profile), - 'sftp' => new SftpUploader($profile), - 'google_drive' => new GoogleDriveUploader($profile), - 's3' => new S3Uploader($profile), - default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage), - }; + if ($index >= count($session->remoteDestinations)) { + // All remotes processed — move to complete + $session->phase = 'complete'; + $session->statusMessage = 'All remote uploads finished'; + $this->completeRecord($session); - $session->log('Starting remote upload (' . $session->remoteStorage . ')...'); - $result = $uploader->upload($session->archivePath, $session->archiveName); + return; + } - if ($result['success']) { - $remoteFilename = $result['remote_path'] ?? $session->archiveName; - $session->log('Remote upload complete: ' . $result['message']); + $remote = (object) $session->remoteDestinations[$index]; - if (!$session->remoteKeepLocal && is_file($session->archivePath)) { - @unlink($session->archivePath); - $session->log('Local copy removed'); + try { + $title = $remote->title ?? ('Remote #' . ($index + 1)); + $type = $remote->type ?? 'unknown'; + $params = json_decode($remote->params ?? '{}', true) ?: []; + + $session->log('Uploading to: ' . $title . ' (' . $type . ')...'); + $uploader = $this->createUploaderFromParams($type, $params); + $result = $uploader->upload($session->archivePath, $session->archiveName); + + if ($result['success']) { + $remoteFilename = $result['remote_path'] ?? $session->archiveName; + $session->log(' Upload complete: ' . $result['message']); + } else { + $uploadFailed = true; + $session->log(' WARNING: Upload failed: ' . $result['message']); } - } else { + } catch (\Throwable $e) { $uploadFailed = true; - $session->log('WARNING: Remote upload failed: ' . $result['message']); + $session->log(' WARNING: Upload exception: ' . $e->getMessage()); + } + + $session->remoteIndex++; + $session->currentStep++; + + $remaining = count($session->remoteDestinations) - $session->remoteIndex; + $session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : ''); + + if ($session->remoteIndex >= count($session->remoteDestinations)) { + // All remotes done — delete local if configured and no failures + if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) { + @unlink($session->archivePath); + $session->log('Local copy removed (remote_keep_local = off)'); + } + + // Update record with remote filename + $update = (object) [ + 'id' => $session->recordId, + 'remote_filename' => $remoteFilename, + 'filesexist' => is_file($session->archivePath) ? 1 : 0, + ]; + $db->updateObject('#__mokosuitebackup_records', $update, 'id'); + + $session->phase = 'complete'; + $session->statusMessage = $uploadFailed + ? 'Backup complete (some remote uploads failed — local archive preserved)' + : 'Backup complete'; + $this->completeRecord($session, $uploadFailed); + } + } else { + // ── Legacy single-remote fallback ────────────────────── + try { + // Reload profile for remote settings + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_profiles')) + ->where($db->quoteName('id') . ' = ' . $session->profileId); + $db->setQuery($query); + $profile = $db->loadObject(); + + $uploader = match ($session->remoteStorage) { + 'ftp' => new FtpUploader($profile), + 'sftp' => new SftpUploader($profile), + 'google_drive' => new GoogleDriveUploader($profile), + 's3' => new S3Uploader($profile), + default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage), + }; + + $session->log('Starting remote upload (' . $session->remoteStorage . ')...'); + $result = $uploader->upload($session->archivePath, $session->archiveName); + + if ($result['success']) { + $remoteFilename = $result['remote_path'] ?? $session->archiveName; + $session->log('Remote upload complete: ' . $result['message']); + + if (!$session->remoteKeepLocal && is_file($session->archivePath)) { + @unlink($session->archivePath); + $session->log('Local copy removed'); + } + } else { + $uploadFailed = true; + $session->log('WARNING: Remote upload failed: ' . $result['message']); + $session->log('Local backup is preserved.'); + } + } catch (\Throwable $e) { + $uploadFailed = true; + $session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage()); $session->log('Local backup is preserved.'); } - } catch (\Throwable $e) { - $uploadFailed = true; - $session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage()); - $session->log('Local backup is preserved.'); + + // Update record with remote filename + $update = (object) [ + 'id' => $session->recordId, + 'remote_filename' => $remoteFilename, + 'filesexist' => is_file($session->archivePath) ? 1 : 0, + ]; + + $db->updateObject('#__mokosuitebackup_records', $update, 'id'); + + $session->currentStep++; + $session->phase = 'complete'; + $session->statusMessage = $uploadFailed + ? 'Backup complete (remote upload failed — local archive preserved)' + : 'Backup complete'; + $this->completeRecord($session, $uploadFailed); } - - // Update record with remote filename - $update = (object) [ - 'id' => $session->recordId, - 'remote_filename' => $remoteFilename, - 'filesexist' => is_file($session->archivePath) ? 1 : 0, - ]; - - $db->updateObject('#__mokosuitebackup_records', $update, 'id'); - - $session->currentStep++; - $session->phase = 'complete'; - $session->statusMessage = $uploadFailed - ? 'Backup complete (remote upload failed — local archive preserved)' - : 'Backup complete'; - $this->completeRecord($session, $uploadFailed); } /** @@ -729,4 +820,58 @@ class SteppedBackupEngine return $tables; } + /** + * Load enabled remote destinations for a profile from the remotes table. + * + * Returns an empty array when the table does not exist (pre-migration) + * so the caller can fall back to the legacy single-remote column. + * + * @param object $db Database driver + * @param int $profileId Profile ID + * + * @return array Array of remote destination rows (as associative arrays for JSON serialization) + */ + private function loadRemoteDestinations(object $db, int $profileId): array + { + try { + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profileId) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + + // Use loadAssocList so the data survives JSON serialization in SteppedSession + return $db->loadAssocList() ?: []; + } catch (\Throwable $e) { + // Table does not exist yet (pre-migration) — fall back to legacy + return []; + } + } + + /** + * Create a remote uploader from JSON params (multi-remote destinations). + * + * Builds a fake profile-like object from the params array so the existing + * uploader constructors work without modification. + * + * @param string $type Remote type: ftp, sftp, s3, google_drive + * @param array $params Key-value params decoded from the remote's JSON + * + * @return RemoteUploaderInterface + */ + private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface + { + $fake = (object) $params; + + return match ($type) { + 'ftp' => new FtpUploader($fake), + 'sftp' => new SftpUploader($fake), + 'google_drive' => new GoogleDriveUploader($fake), + 's3' => new S3Uploader($fake), + default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), + }; + } + } diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php index 2907eec..8fe51ea 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php @@ -55,6 +55,10 @@ class SteppedSession public bool $remoteKeepLocal = true; public string $encryptionPassword = ''; + // Multi-remote destinations (loaded from #__mokosuitebackup_remotes) + public array $remoteDestinations = []; + public int $remoteIndex = 0; + // Progress public int $totalSteps = 0; public int $currentStep = 0; diff --git a/source/packages/com_mokosuitebackup/src/Model/RemoteModel.php b/source/packages/com_mokosuitebackup/src/Model/RemoteModel.php new file mode 100644 index 0000000..3247f42 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Model/RemoteModel.php @@ -0,0 +1,67 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\AdminModel; + +class RemoteModel extends AdminModel +{ + public function getForm($data = [], $loadData = true) + { + $form = $this->loadForm( + 'com_mokosuitebackup.remote', + 'remote', + ['control' => 'jform', 'load_data' => $loadData] + ); + + return $form ?: false; + } + + protected function loadFormData(): object + { + $data = Factory::getApplication()->getUserState('com_mokosuitebackup.edit.remote.data', []); + + if (empty($data)) { + $data = $this->getItem(); + } + + return is_array($data) ? (object) $data : $data; + } + + public function getTable($name = 'Remote', $prefix = 'Administrator', $options = []) + { + return parent::getTable($name, $prefix, $options); + } + + /** + * Get all enabled remotes for a given profile. + * + * @param int $profileId The profile ID + * + * @return array Array of remote objects + */ + public function getEnabledByProfile(int $profileId): array + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profileId) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Model/RemotesModel.php b/source/packages/com_mokosuitebackup/src/Model/RemotesModel.php new file mode 100644 index 0000000..f49d503 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Model/RemotesModel.php @@ -0,0 +1,88 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Model\ListModel; +use Joomla\Database\QueryInterface; + +class RemotesModel extends ListModel +{ + public function __construct($config = []) + { + if (empty($config['filter_fields'])) { + $config['filter_fields'] = [ + 'id', 'a.id', + 'profile_id', 'a.profile_id', + 'title', 'a.title', + 'type', 'a.type', + 'enabled', 'a.enabled', + 'ordering', 'a.ordering', + ]; + } + + parent::__construct($config); + } + + protected function getListQuery(): QueryInterface + { + $db = $this->getDatabase(); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokosuitebackup_remotes', 'a')); + + // Join profile title + $query->select($db->quoteName('p.title', 'profile_title')) + ->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = a.profile_id'); + + // Filter by profile + $profileId = $this->getState('filter.profile_id'); + + if (is_numeric($profileId)) { + $query->where($db->quoteName('a.profile_id') . ' = ' . (int) $profileId); + } + + // Filter by type + $type = $this->getState('filter.type'); + + if (!empty($type)) { + $query->where($db->quoteName('a.type') . ' = ' . $db->quote($type)); + } + + // Filter by enabled + $enabled = $this->getState('filter.enabled'); + + if (is_numeric($enabled)) { + $query->where($db->quoteName('a.enabled') . ' = ' . (int) $enabled); + } + + // Filter by search + $search = $this->getState('filter.search'); + + if (!empty($search)) { + $search = $db->quote('%' . $db->escape(trim($search), true) . '%'); + $query->where('(' . $db->quoteName('a.title') . ' LIKE ' . $search . ')'); + } + + $orderCol = $this->state->get('list.ordering', 'a.ordering'); + $orderDir = $this->state->get('list.direction', 'ASC'); + $query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir)); + + return $query; + } + + protected function populateState($ordering = 'a.ordering', $direction = 'ASC'): void + { + parent::populateState($ordering, $direction); + } +} diff --git a/source/packages/com_mokosuitebackup/src/Table/RemoteTable.php b/source/packages/com_mokosuitebackup/src/Table/RemoteTable.php new file mode 100644 index 0000000..e7dfac2 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Table/RemoteTable.php @@ -0,0 +1,94 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Table; + +defined('_JEXEC') or die; + +use Joomla\CMS\Table\Table; +use Joomla\Database\DatabaseDriver; + +class RemoteTable extends Table +{ + public function __construct(DatabaseDriver $db) + { + parent::__construct('#__mokosuitebackup_remotes', 'id', $db); + } + + public function check(): bool + { + if (empty($this->profile_id)) { + $this->setError('Profile ID is required.'); + + return false; + } + + $validTypes = ['sftp', 's3', 'google_drive', 'ftp']; + + if (empty($this->type) || !\in_array($this->type, $validTypes, true)) { + $this->setError('Invalid remote type. Must be one of: ' . implode(', ', $validTypes)); + + return false; + } + + if (empty($this->title)) { + $this->title = ucfirst(str_replace('_', ' ', $this->type)) . ' Remote'; + } + + // Ensure params is valid JSON + if (!empty($this->params) && \is_string($this->params)) { + $decoded = json_decode($this->params); + + if (json_last_error() !== JSON_ERROR_NONE) { + $this->setError('Remote params must be valid JSON.'); + + return false; + } + } + + $now = date('Y-m-d H:i:s'); + + if (empty($this->created) || $this->created === '0000-00-00 00:00:00') { + $this->created = $now; + } + + $this->modified = $now; + + return true; + } + + /** + * Get the params as a decoded object. + * + * @return object + */ + public function getParams(): object + { + if (empty($this->params)) { + return (object) []; + } + + $decoded = json_decode($this->params); + + return \is_object($decoded) ? $decoded : (object) []; + } + + /** + * Set params from an array or object, encoding to JSON. + * + * @param array|object $params The parameters to encode + * + * @return void + */ + public function setParams(array|object $params): void + { + $this->params = json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +} diff --git a/source/packages/com_mokosuitebackup/tmpl/profile/edit.php b/source/packages/com_mokosuitebackup/tmpl/profile/edit.php index ce77be9..1f6e636 100644 --- a/source/packages/com_mokosuitebackup/tmpl/profile/edit.php +++ b/source/packages/com_mokosuitebackup/tmpl/profile/edit.php @@ -13,11 +13,15 @@ defined('_JEXEC') or die; use Joomla\CMS\HTML\HTMLHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; HTMLHelper::_('behavior.formvalidator'); HTMLHelper::_('behavior.keepalive'); + +$profileId = (int) $this->item->id; +$token = Session::getFormToken(); ?> -
@@ -60,11 +64,53 @@ HTMLHelper::_('behavior.keepalive');
-
- form->renderFieldset('remote'); ?> - form->renderFieldset('ftp'); ?> - form->renderFieldset('google_drive'); ?> - form->renderFieldset('s3'); ?> +
+ + +
+
+

+ +
+ + + + + + + + + + + + + + + +
+ +
+ +
+
+ + + +
+ + form->renderFieldset('remote'); ?> + form->renderFieldset('ftp'); ?> + form->renderFieldset('google_drive'); ?> + form->renderFieldset('s3'); ?> +
@@ -75,3 +121,495 @@ HTMLHelper::_('behavior.keepalive'); + + + + + + +