diff --git a/CHANGELOG.md b/CHANGELOG.md index 9803a102..f7adef33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Changed +- Remote upload failure no longer marks the entire backup as failed — local archive is preserved with status 'complete' (#66) + +### Added +- Snapshots now capture tags, custom fields, field values, and field-category mappings when articles are included (#57) +- Snapshot retention settings: max count and max age with automatic cleanup (#63) + ## [01.27.03] --- 2026-06-21 ## [01.27.03] --- 2026-06-21 diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index fb09db5c..e45aa945 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -255,26 +255,36 @@ 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'; if ($remoteStorage !== 'none') { - $this->log('Starting remote upload (' . $remoteStorage . ')...'); - $uploader = $this->createUploader($remoteStorage, $profile); - $uploadResult = $uploader->upload($archivePath, $archiveName); + 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 ($uploadResult['success']) { + $remoteFilename = $uploadResult['remote_path'] ?? $archiveName; + $this->log('Remote upload complete: ' . $uploadResult['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)'); + // 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.'); } - } else { - $this->log('WARNING: Remote upload failed: ' . $uploadResult['message']); + } catch (\Throwable $e) { + $uploadFailed = true; + $this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage()); $this->log('Local backup is preserved.'); } } @@ -309,9 +319,14 @@ class BackupEngine $db->updateObject('#__mokosuitebackup_records', $update, 'id'); - // Send success notification + // Send success notification (backup completed, even if upload failed) NotificationSender::send($profile, $update, true, implode("\n", $this->log)); + // If remote upload failed, also send a failure notification for the upload + if ($uploadFailed) { + NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log)); + } + // Dispatch event for actionlog and other listeners $this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin); diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index 6ba97ea6..e6a0f368 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -389,37 +389,47 @@ class SteppedBackupEngine private function stepUpload(SteppedSession $session): void { $db = Factory::getDbo(); - - // 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), - '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); - $remoteFilename = ''; + $uploadFailed = false; - if ($result['success']) { - $remoteFilename = $result['remote_path'] ?? $session->archiveName; - $session->log('Remote upload complete: ' . $result['message']); + // 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 (!$session->remoteKeepLocal && is_file($session->archivePath)) { - @unlink($session->archivePath); - $session->log('Local copy removed'); + $uploader = match ($session->remoteStorage) { + 'ftp' => new FtpUploader($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.'); } - } else { - $session->log('WARNING: Remote upload failed: ' . $result['message']); + } 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 @@ -433,14 +443,16 @@ class SteppedBackupEngine $session->currentStep++; $session->phase = 'complete'; - $session->statusMessage = 'Backup complete'; - $this->completeRecord($session); + $session->statusMessage = $uploadFailed + ? 'Backup complete (remote upload failed — local archive preserved)' + : 'Backup complete'; + $this->completeRecord($session, $uploadFailed); } /** * Mark the backup record as complete. */ - private function completeRecord(SteppedSession $session): void + private function completeRecord(SteppedSession $session, bool $uploadFailed = false): void { $db = Factory::getDbo(); $logContent = implode("\n", $session->log); @@ -490,6 +502,11 @@ class SteppedBackupEngine ]; NotificationSender::send($profile, $record, true, $logContent); + + // If remote upload failed, also send a failure notification for the upload + if ($uploadFailed) { + NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent); + } } } catch (\Throwable $e) { error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());