diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 11958bd..2b91385 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.00.00 +# VERSION: 02.52.25 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/source/packages/MokoSuiteClient b/source/packages/MokoSuiteClient index 7879ecb..9df6bea 160000 --- a/source/packages/MokoSuiteClient +++ b/source/packages/MokoSuiteClient @@ -1 +1 @@ -Subproject commit 7879ecbf1f91ddf3b474465a93252f6ddc2f0a3a +Subproject commit 9df6bea4b7480b2e443898ad84a279070ba4a7f6 diff --git a/source/packages/com_mokosuitebackup/forms/profile.xml b/source/packages/com_mokosuitebackup/forms/profile.xml index 81a46de..d7fa692 100644 --- a/source/packages/com_mokosuitebackup/forms/profile.xml +++ b/source/packages/com_mokosuitebackup/forms/profile.xml @@ -206,25 +206,6 @@
- - - - - diff --git a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini index 1cb697a..2f31ce6 100644 --- a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini @@ -127,6 +127,10 @@ COM_MOKOJOOMBACKUP_CANCEL_SUCCESS="%d stalled backup(s) cancelled." ; Backup status COM_MOKOJOOMBACKUP_STATUS_WARNING="Warning" +; Delete feedback +COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED="%d backup records deleted." +COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED_1="%d backup record deleted." + ; ACL - Cancel COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL="Cancel Stalled Backup" COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL_DESC="Allows users to cancel backup records stuck in running status and clean up partial archive files." diff --git a/source/packages/com_mokosuitebackup/sql/install.mysql.sql b/source/packages/com_mokosuitebackup/sql/install.mysql.sql index d1c24aa..a2c23fd 100644 --- a/source/packages/com_mokosuitebackup/sql/install.mysql.sql +++ b/source/packages/com_mokosuitebackup/sql/install.mysql.sql @@ -11,32 +11,6 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` ( `exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude', `exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude', `exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude', - `remote_storage` VARCHAR(20) NOT NULL DEFAULT 'none' COMMENT 'none, ftp, google_drive, s3', - `ftp_host` VARCHAR(255) NOT NULL DEFAULT '', - `ftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 21, - `ftp_username` VARCHAR(255) NOT NULL DEFAULT '', - `ftp_password` VARCHAR(255) NOT NULL DEFAULT '', - `ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups', - `ftp_passive` TINYINT(1) NOT NULL DEFAULT 1, - `ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0, - `sftp_host` VARCHAR(255) NOT NULL DEFAULT '', - `sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22, - `sftp_username` VARCHAR(255) NOT NULL DEFAULT '', - `sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key', - `sftp_password` VARCHAR(255) NOT NULL DEFAULT '', - `sftp_key_data` MEDIUMTEXT, - `sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '', - `sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups', - `gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '', - `gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '', - `gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '', - `gdrive_folder_id` VARCHAR(255) NOT NULL DEFAULT '', - `s3_endpoint` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'S3 endpoint URL (blank = AWS default)', - `s3_region` VARCHAR(50) NOT NULL DEFAULT 'us-east-1', - `s3_access_key` VARCHAR(255) NOT NULL DEFAULT '', - `s3_secret_key` VARCHAR(255) NOT NULL DEFAULT '', - `s3_bucket` VARCHAR(255) NOT NULL DEFAULT '', - `s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups', `remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload', `encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)', `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone', diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.25.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.25.sql new file mode 100644 index 0000000..550a037 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.25.sql @@ -0,0 +1,31 @@ +-- Remove legacy single-remote storage columns (superseded by #__mokosuitebackup_remotes). +-- Plain DROP COLUMN (no IF EXISTS): all columns are created by install.mysql.sql and +-- earlier updates, so they always exist here. `DROP COLUMN IF EXISTS` is a MariaDB-only +-- extension and errors on Oracle MySQL 8.x, which Joomla also supports. +ALTER TABLE `#__mokosuitebackup_profiles` + DROP COLUMN `remote_storage`, + DROP COLUMN `ftp_host`, + DROP COLUMN `ftp_port`, + DROP COLUMN `ftp_username`, + DROP COLUMN `ftp_password`, + DROP COLUMN `ftp_path`, + DROP COLUMN `ftp_passive`, + DROP COLUMN `ftp_ssl`, + DROP COLUMN `sftp_host`, + DROP COLUMN `sftp_port`, + DROP COLUMN `sftp_username`, + DROP COLUMN `sftp_auth_type`, + DROP COLUMN `sftp_password`, + DROP COLUMN `sftp_key_data`, + DROP COLUMN `sftp_passphrase`, + DROP COLUMN `sftp_path`, + DROP COLUMN `gdrive_client_id`, + DROP COLUMN `gdrive_client_secret`, + DROP COLUMN `gdrive_refresh_token`, + DROP COLUMN `gdrive_folder_id`, + DROP COLUMN `s3_endpoint`, + DROP COLUMN `s3_region`, + DROP COLUMN `s3_access_key`, + DROP COLUMN `s3_secret_key`, + DROP COLUMN `s3_bucket`, + DROP COLUMN `s3_path`; diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index 264464f..c3d396f 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -1265,184 +1265,6 @@ class AjaxController extends BaseController return $config; } - /** - * Browse directories on a remote SFTP server for the path picker. - * POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path - */ - public function browseSftpDir(): 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; - } - - $profileId = $this->input->getInt('profile_id', 0); - - if (!$profileId) { - $this->sendJson(['error' => true, 'message' => 'Missing profile_id']); - - return; - } - - /* Load the profile to get SFTP credentials */ - try { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select('*') - ->from($db->quoteName('#__mokosuitebackup_profiles')) - ->where($db->quoteName('id') . ' = ' . $profileId); - $db->setQuery($query); - $profile = $db->loadObject(); - } catch (\Exception $e) { - $this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500); - - return; - } - - if (!$profile) { - $this->sendJson(['error' => true, 'message' => 'Profile not found'], 404); - - return; - } - - $host = $profile->sftp_host ?? ''; - $port = (int) ($profile->sftp_port ?? 22); - $username = $profile->sftp_username ?? ''; - $keyData = $profile->sftp_key_data ?? ''; - $password = $profile->sftp_password ?? ''; - - if (empty($host) || empty($username)) { - $this->sendJson(['error' => true, 'message' => 'SFTP host and username must be configured and saved before browsing']); - - return; - } - - if (empty($keyData) && empty($password)) { - $this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']); - - return; - } - - $requestPath = $this->input->getString('path', '/'); - - /* Sanitize: must start with / and not contain shell meta-characters */ - $requestPath = '/' . ltrim($requestPath, '/'); - - if (preg_match('/[;&|`$<>]/', $requestPath)) { - $this->sendJson(['error' => true, 'message' => 'Invalid path characters']); - - return; - } - - $keyFile = null; - - try { - /* Write temp key if using key auth (same pattern as SftpUploader) */ - if (!empty($keyData)) { - $keyContent = base64_decode($keyData, true); - - if ($keyContent === false) { - $keyContent = $keyData; - } - - $keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key'; - - if (file_put_contents($keyFile, $keyContent) === false) { - throw new \RuntimeException('Cannot write temporary SSH key file'); - } - - chmod($keyFile, 0600); - } - - /* Build SSH command to list directories */ - $escapedPath = escapeshellarg($requestPath); - $remoteCmd = 'ls -1pa ' . $escapedPath . ' 2>/dev/null | grep "/$"'; - - $parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10']; - - if ($port !== 22) { - $parts[] = '-p'; - $parts[] = (string) $port; - } - - if ($keyFile !== null) { - $parts[] = '-i'; - $parts[] = escapeshellarg($keyFile); - } - - $parts[] = escapeshellarg($username . '@' . $host); - $parts[] = escapeshellarg($remoteCmd); - - $cmd = implode(' ', $parts); - - $output = []; - $exitCode = 0; - exec($cmd . ' 2>&1', $output, $exitCode); - - /* exitCode 1 from grep means no matches (empty dir), which is OK */ - if ($exitCode !== 0 && $exitCode !== 1) { - throw new \RuntimeException('SSH command failed (exit ' . $exitCode . '): ' . implode(' ', $output)); - } - - /* Parse output: each line is a directory name ending with / */ - $dirs = []; - - foreach ($output as $line) { - $line = trim($line); - - if ($line === '' || $line === './' || $line === '../') { - continue; - } - - $dirName = rtrim($line, '/'); - - if ($dirName === '' || $dirName === '.' || $dirName === '..') { - continue; - } - - $fullPath = rtrim($requestPath, '/') . '/' . $dirName; - - $dirs[] = [ - 'name' => $dirName, - 'path' => $fullPath, - ]; - } - - usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name'])); - - /* Parent path */ - $parent = null; - - if ($requestPath !== '/') { - $parent = \dirname($requestPath); - - if ($parent === '') { - $parent = '/'; - } - } - - $this->sendJson([ - 'error' => false, - 'current' => $requestPath, - 'parent' => $parent, - 'dirs' => $dirs, - ]); - } catch (\Throwable $e) { - $this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]); - } finally { - if ($keyFile !== null && is_file($keyFile)) { - unlink($keyFile); - } - } - } - /** * Send a JSON response and close the application. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php b/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php index 7020926..79dca60 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php +++ b/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php @@ -228,24 +228,9 @@ class AkeebaImporter 'exclude_dirs' => implode("\n", $filters['exclude_dirs']), 'exclude_files' => implode("\n", $filters['exclude_files']), 'exclude_tables' => implode("\n", $filters['exclude_tables']), - 'remote_storage' => $this->mapRemoteStorage($config), - 'ftp_host' => $config['engine.postproc.ftp.host'] ?? '', - 'ftp_port' => (int) ($config['engine.postproc.ftp.port'] ?? 21), - 'ftp_username' => $config['engine.postproc.ftp.user'] ?? '', - 'ftp_password' => $config['engine.postproc.ftp.pass'] ?? '', - 'ftp_path' => $config['engine.postproc.ftp.initial_directory'] ?? '/backups', - 'ftp_passive' => (int) ($config['engine.postproc.ftp.passive_mode'] ?? 1), - 'ftp_ssl' => (int) ($config['engine.postproc.ftp.ftps'] ?? 0), - 'gdrive_client_id' => $config['engine.postproc.googledrive.client_id'] ?? '', - 'gdrive_client_secret' => $config['engine.postproc.googledrive.client_secret'] ?? '', - 'gdrive_refresh_token' => $config['engine.postproc.googledrive.refresh_token'] ?? '', - 'gdrive_folder_id' => $config['engine.postproc.googledrive.directory'] ?? '', - 's3_endpoint' => $config['engine.postproc.s3.custom_endpoint'] ?? '', - 's3_region' => $config['engine.postproc.s3.region'] ?? 'us-east-1', - 's3_access_key' => $config['engine.postproc.s3.access_key'] ?? ($config['engine.postproc.s3.accesskey'] ?? ''), - 's3_secret_key' => $config['engine.postproc.s3.secret_key'] ?? ($config['engine.postproc.s3.secretkey'] ?? ''), - 's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '', - 's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups', + // Remote storage is no longer stored on the profile — it lives in + // #__mokosuitebackup_remotes. Akeeba remote settings are not imported; + // re-add remote destinations on the profile's Remote tab after import. 'remote_keep_local' => 1, 'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'), 'published' => 1, diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index e1a818c..d9105d3 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -321,48 +321,6 @@ class BackupEngine @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']); - - if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) { - $restoreBasename = basename($restoreScriptPath); - $this->log('Uploading standalone ' . $restoreBasename . '...'); - $restoreUpload = $uploader->upload($restoreScriptPath, $restoreBasename); - - if ($restoreUpload['success']) { - $this->log('Standalone ' . $restoreBasename . ' uploaded'); - } else { - $this->log('WARNING: ' . $restoreBasename . ' 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.'); - } - } } // Write log file alongside the archive @@ -530,23 +488,7 @@ 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 - { - return match ($type) { - 'ftp' => new FtpUploader($profile), - 'sftp' => new SftpUploader($profile), - 'google_drive' => new GoogleDriveUploader($profile), - 's3' => new S3Uploader($profile), - default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), - }; - } - - /** - * Create a remote uploader from JSON params (multi-remote destinations). + * Create a remote uploader from JSON params. * * Builds a fake profile-like object from the params array so the existing * uploader constructors work without modification. @@ -580,31 +522,18 @@ class BackupEngine /** * 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); + $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 []; - } + return $db->loadObjectList() ?: []; } /** diff --git a/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php b/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php index 664a3e8..6ccbd90 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php +++ b/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php @@ -77,7 +77,7 @@ class PreflightCheck $this->checkDiskSpace($profile, $db); $this->checkRunningBackup($profile, $db); $this->checkExcludedTables($profile, $db); - $this->checkRemoteCredentials($profile); + $this->checkRemoteCredentials($profile, $db); return $this->result(); } @@ -102,12 +102,8 @@ class PreflightCheck } } - // curl is only needed for remote upload and ntfy notifications - $needsCurl = ($profile->remote_storage ?? 'none') !== 'none' - || !empty($profile->ntfy_topic); - - if ($needsCurl && !extension_loaded('curl')) { - $this->warnings[] = 'ext-curl is not loaded — remote upload and ntfy notifications will not work'; + if (!empty($profile->ntfy_topic) && !extension_loaded('curl')) { + $this->warnings[] = 'ext-curl is not loaded — ntfy notifications will not work'; } } @@ -280,65 +276,76 @@ class PreflightCheck } /** - * Check that remote storage credentials are minimally configured. + * Check that remote destination credentials are minimally configured. * Does not test the actual connection (too slow for preflight). */ - private function checkRemoteCredentials(object $profile): void + private function checkRemoteCredentials(object $profile, object $db): void { - $remote = $profile->remote_storage ?? 'none'; + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) + ->where($db->quoteName('enabled') . ' = 1'); + $db->setQuery($query); + $remotes = $db->loadObjectList(); - if ($remote === 'none') { + if (empty($remotes)) { return; } - switch ($remote) { - case 'ftp': - if (empty($profile->ftp_host)) { - $this->warnings[] = 'FTP host is not configured — remote upload will fail'; - } + foreach ($remotes as $remote) { + $params = json_decode($remote->params, true) ?: []; + $label = $remote->title ?: ('Remote #' . $remote->id); - if (empty($profile->ftp_username)) { - $this->warnings[] = 'FTP username is not configured — remote upload will fail'; - } + switch ($remote->type) { + case 'ftp': + if (empty($params['host'])) { + $this->warnings[] = $label . ': FTP host is not configured — upload will fail'; + } - break; + if (empty($params['username'])) { + $this->warnings[] = $label . ': FTP username is not configured — upload will fail'; + } - case 's3': - if (empty($profile->s3_bucket)) { - $this->warnings[] = 'S3 bucket is not configured — remote upload will fail'; - } + break; - if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) { - $this->warnings[] = 'S3 credentials are not configured — remote upload will fail'; - } + case 's3': + if (empty($params['bucket'])) { + $this->warnings[] = $label . ': S3 bucket is not configured — upload will fail'; + } - break; + if (empty($params['access_key']) || empty($params['secret_key'])) { + $this->warnings[] = $label . ': S3 credentials are not configured — upload will fail'; + } - case 'sftp': - if (empty($profile->sftp_host)) { - $this->warnings[] = 'SFTP host is not configured — remote upload will fail'; - } + break; - if (empty($profile->sftp_username)) { - $this->warnings[] = 'SFTP username is not configured — remote upload will fail'; - } + case 'sftp': + if (empty($params['host'])) { + $this->warnings[] = $label . ': SFTP host is not configured — upload will fail'; + } - if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) { - $this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail'; - } + if (empty($params['username'])) { + $this->warnings[] = $label . ': SFTP username is not configured — upload will fail'; + } - break; + if (empty($params['key_data']) && empty($params['password'])) { + $this->warnings[] = $label . ': SFTP requires either a private key or password — upload will fail'; + } - case 'google_drive': - if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) { - $this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail'; - } + break; - if (empty($profile->gdrive_refresh_token)) { - $this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail'; - } + case 'google_drive': + if (empty($params['client_id']) || empty($params['client_secret'])) { + $this->warnings[] = $label . ': Google Drive OAuth credentials are not configured — upload will fail'; + } - break; + if (empty($params['refresh_token'])) { + $this->warnings[] = $label . ': Google Drive refresh token is missing — upload will fail'; + } + + break; + } } } diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index b78eb19..bbcf906 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -69,7 +69,6 @@ class SteppedBackupEngine $session->excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? ''); $session->excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? ''); $session->backupDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER; - $session->remoteStorage = $profile->remote_storage ?? 'none'; $session->includeMokoRestore = $profile->include_mokorestore ?? '0'; $session->restoreScriptName = $profile->restore_script_name ?? 'restore.php'; $session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); @@ -153,15 +152,8 @@ class SteppedBackupEngine $totalSteps += 1; // finalize 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; - } + $totalSteps += $remoteCount; $session->totalSteps = $totalSteps; $session->currentStep = 1; @@ -421,11 +413,7 @@ class SteppedBackupEngine $session->currentStep++; - // Determine next phase: multi-remote, legacy single-remote, or complete - $hasMultiRemote = !empty($session->remoteDestinations); - $hasLegacyRemote = $session->remoteStorage !== 'none'; - - if ($hasMultiRemote || $hasLegacyRemote) { + if (!empty($session->remoteDestinations)) { $session->phase = 'upload'; } else { $session->phase = 'complete'; @@ -440,11 +428,7 @@ 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. + * Upload phase: send archive to one remote destination per call. */ private function stepUpload(SteppedSession $session): void { @@ -452,133 +436,65 @@ class SteppedBackupEngine $remoteFilename = ''; $uploadFailed = false; - if (!empty($session->remoteDestinations)) { - // ── Multi-remote path ────────────────────────────────── - $index = $session->remoteIndex; + $index = $session->remoteIndex; - if ($index >= count($session->remoteDestinations)) { - // All remotes processed — move to complete - $session->phase = 'complete'; - $session->statusMessage = 'All remote uploads finished'; - $this->completeRecord($session); + if ($index >= count($session->remoteDestinations)) { + $session->phase = 'complete'; + $session->statusMessage = 'All remote uploads finished'; + $this->completeRecord($session); - return; - } + return; + } - $remote = (object) $session->remoteDestinations[$index]; + $remote = (object) $session->remoteDestinations[$index]; - try { - $title = $remote->title ?? ('Remote #' . ($index + 1)); - $type = $remote->type ?? 'unknown'; - $params = json_decode($remote->params ?? '{}', true) ?: []; + 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); + $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']); + if ($result['success']) { + $remoteFilename = $result['remote_path'] ?? $session->archiveName; + $session->log(' Upload complete: ' . $result['message']); - if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) { - $uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath)); - } - } else { - $uploadFailed = true; - $session->log(' WARNING: Upload failed: ' . $result['message']); + if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) { + $uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath)); } - } catch (\Throwable $e) { + } else { $uploadFailed = true; - $session->log(' WARNING: Upload exception: ' . $e->getMessage()); + $session->log(' WARNING: Upload failed: ' . $result['message']); + } + } catch (\Throwable $e) { + $uploadFailed = true; + $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)) { + if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) { + @unlink($session->archivePath); + $session->log('Local copy removed (remote_keep_local = off)'); } - $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 (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) { - $restoreBasename = basename($session->restoreScriptPath); - $session->log('Uploading standalone ' . $restoreBasename . '...'); - $uploader->upload($session->restoreScriptPath, $restoreBasename); - } - - 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.'); - } - - // 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 (some remote uploads failed — local archive preserved)' : 'Backup complete'; $this->completeRecord($session, $uploadFailed); } @@ -866,21 +782,15 @@ class SteppedBackupEngine */ 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); + $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 []; - } + return $db->loadAssocList() ?: []; } /** diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php index c153c52..3a103b3 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php @@ -50,7 +50,6 @@ class SteppedSession public array $excludeDirs = []; public array $excludeFiles = []; public array $excludeTables = []; - public string $remoteStorage = 'none'; public string $includeMokoRestore = '0'; public string $restoreScriptName = 'restore.php'; public string $restoreScriptPath = ''; diff --git a/source/packages/com_mokosuitebackup/src/Field/SftpPathField.php b/source/packages/com_mokosuitebackup/src/Field/SftpPathField.php deleted file mode 100644 index b501999..0000000 --- a/source/packages/com_mokosuitebackup/src/Field/SftpPathField.php +++ /dev/null @@ -1,253 +0,0 @@ - - * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. - * @license GNU General Public License version 3 or later; see LICENSE - * - * SFTP remote path field with Browse Remote button and modal directory browser. - */ - -namespace Joomla\Component\MokoSuiteBackup\Administrator\Field; - -defined('_JEXEC') or die; - -use Joomla\CMS\Form\FormField; - -class SftpPathField extends FormField -{ - protected $type = 'SftpPath'; - - protected function getInput(): string - { - $value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8'); - $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); - $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); - - return << - - - - - -HTML; - } -} diff --git a/source/packages/com_mokosuitebackup/tmpl/profile/edit.php b/source/packages/com_mokosuitebackup/tmpl/profile/edit.php index a19611e..85ccad8 100644 --- a/source/packages/com_mokosuitebackup/tmpl/profile/edit.php +++ b/source/packages/com_mokosuitebackup/tmpl/profile/edit.php @@ -66,7 +66,6 @@ $token = Session::getFormToken();