From 974b971340b9ab04b59e31c94c8d30b8442b42ff Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 21 Jun 2026 19:08:27 -0500 Subject: [PATCH 1/4] feat: snapshot retention and automatic cleanup (#63) Add retention settings for content snapshots (max count, max age days) in component options. System plugin runs cleanupOldSnapshots() alongside existing backup cleanup, deleting JSON files and DB records. Closes #63 --- .../packages/com_mokosuitebackup/config.xml | 21 +++++ .../language/en-GB/com_mokosuitebackup.ini | 7 ++ .../src/Extension/MokoSuiteBackup.php | 88 +++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/source/packages/com_mokosuitebackup/config.xml b/source/packages/com_mokosuitebackup/config.xml index 60428d2..6fdd1ad 100644 --- a/source/packages/com_mokosuitebackup/config.xml +++ b/source/packages/com_mokosuitebackup/config.xml @@ -118,6 +118,27 @@ /> +
+ + +
+
set('mokosuitebackup.last_cleanup', time()); $this->cleanupOldBackups(); + $this->cleanupOldSnapshots(); } /** @@ -152,6 +153,93 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface } } + /** + * Remove old content snapshots per component retention settings. + * + * Respects snapshot_retention_days (max age) and snapshot_retention_count + * (max number to keep). A value of 0 means unlimited for that setting. + */ + private function cleanupOldSnapshots(): void + { + try { + $this->doSnapshotCleanup(); + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: cleanupOldSnapshots() failed: ' . $e->getMessage()); + } + } + + private function doSnapshotCleanup(): void + { + $db = Factory::getDbo(); + $params = ComponentHelper::getParams('com_mokosuitebackup'); + $retentionDays = (int) $params->get('snapshot_retention_days', 30); + $retentionCount = (int) $params->get('snapshot_retention_count', 20); + + // Delete snapshots older than retention_days + if ($retentionDays > 0) { + $cutoff = date('Y-m-d H:i:s', strtotime("-{$retentionDays} days")); + $query = $db->getQuery(true) + ->select([$db->quoteName('id'), $db->quoteName('data_file')]) + ->from($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('created') . ' < ' . $db->quote($cutoff)) + ->order($db->quoteName('created') . ' DESC'); + $db->setQuery($query); + $expired = $db->loadObjectList(); + + foreach ($expired as $snapshot) { + $this->deleteSnapshotRecord($db, $snapshot); + } + } + + // Enforce max count (keep newest) + if ($retentionCount > 0) { + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitebackup_snapshots')); + $db->setQuery($query); + $totalCount = (int) $db->loadResult(); + + if ($totalCount > $retentionCount) { + $excess = $totalCount - $retentionCount; + $query = $db->getQuery(true) + ->select([$db->quoteName('id'), $db->quoteName('data_file')]) + ->from($db->quoteName('#__mokosuitebackup_snapshots')) + ->order($db->quoteName('created') . ' ASC'); + $db->setQuery($query, 0, $excess); + $oldest = $db->loadObjectList(); + + foreach ($oldest as $snapshot) { + $this->deleteSnapshotRecord($db, $snapshot); + } + } + } + } + + /** + * Delete a snapshot record and its JSON data file. + */ + private function deleteSnapshotRecord(object $db, object $snapshot): void + { + if (!empty($snapshot->data_file) && is_file($snapshot->data_file)) { + if (!@unlink($snapshot->data_file)) { + error_log('MokoSuiteBackup: Could not delete snapshot file (id=' . $snapshot->id . '): ' . $snapshot->data_file); + + return; + } + } + + try { + $db->setQuery( + $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . (int) $snapshot->id) + ); + $db->execute(); + } catch (\Exception $e) { + error_log('MokoSuiteBackup: Could not delete snapshot record ' . $snapshot->id . ': ' . $e->getMessage()); + } + } + private function doCleanup(): void { $db = Factory::getDbo(); -- 2.52.0 From eb7f48d3a26ed30d120062b5ab1070f58f28bc07 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 21 Jun 2026 19:08:53 -0500 Subject: [PATCH 2/4] feat: extend snapshots to include custom fields and tags (#57) When articles are included in a snapshot, now also captures: - #__tags (tag definitions) - #__fields (custom field definitions for com_content.article) - #__fields_values (custom field values) - #__fields_categories (field-to-category mappings) Restore correctly scopes deletes to avoid touching non-content fields. Closes #57 --- .../src/Engine/SnapshotEngine.php | 54 +++++++++++++++++++ .../src/Engine/SnapshotRestoreEngine.php | 42 +++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php index 4ea16a3..f47b045 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php @@ -41,6 +41,10 @@ class SnapshotEngine private const ARTICLE_RELATED = [ '#__workflow_associations', '#__contentitem_tag_map', + '#__tags', + '#__fields', + '#__fields_values', + '#__fields_categories', ]; /** @@ -107,6 +111,32 @@ class SnapshotEngine $rows = $this->dumpTagMap($db, $prefix); $data['tables']['#__contentitem_tag_map'] = $rows; $this->log(' #__contentitem_tag_map: ' . count($rows) . ' rows'); + + // Tags — dump all (shared, small table) + $rows = $this->dumpTable($db, str_replace('#__', $prefix, '#__tags'), '#__tags', 'articles'); + $data['tables']['#__tags'] = $rows; + $this->log(' #__tags: ' . count($rows) . ' rows'); + + // Custom fields — only com_content.article context + $rows = $this->dumpFilteredTable( + $db, + str_replace('#__', $prefix, '#__fields'), + '#__fields', + 'context', + 'com_content.article' + ); + $data['tables']['#__fields'] = $rows; + $this->log(' #__fields: ' . count($rows) . ' rows'); + + // Field values — dump all (small, article-scoped) + $rows = $this->dumpTable($db, str_replace('#__', $prefix, '#__fields_values'), '#__fields_values', 'articles'); + $data['tables']['#__fields_values'] = $rows; + $this->log(' #__fields_values: ' . count($rows) . ' rows'); + + // Field-category mappings — only for com_content.article fields + $rows = $this->dumpFieldCategories($db, $prefix); + $data['tables']['#__fields_categories'] = $rows; + $this->log(' #__fields_categories: ' . count($rows) . ' rows'); } // Count items @@ -231,6 +261,30 @@ class SnapshotEngine return $db->loadAssocList() ?: []; } + /** + * Dump field-category mappings for com_content.article fields. + * + * Uses a subquery: field_id IN (SELECT id FROM #__fields WHERE context = 'com_content.article') + */ + private function dumpFieldCategories(object $db, string $prefix): array + { + $fcTable = $prefix . 'fields_categories'; + $fTable = $prefix . 'fields'; + + $subQuery = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName($fTable)) + ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName($fcTable)) + ->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + private function log(string $message): void { $this->log[] = '[' . date('H:i:s') . '] ' . $message; diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index 6bb514f..665c301 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -33,6 +33,10 @@ class SnapshotRestoreEngine '#__contentitem_tag_map' => null, // composite key, handled specially '#__modules' => 'id', '#__modules_menu' => null, // composite key, handled specially + '#__tags' => 'id', + '#__fields' => 'id', + '#__fields_values' => null, // composite key, handled specially + '#__fields_categories' => null, // composite key, handled specially ]; /** @@ -282,6 +286,40 @@ class SnapshotRestoreEngine $query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')'); break; + case '#__tags': + // Only delete tags that exist in the snapshot — never wipe all tags + $ids = array_filter(array_column($rows, 'id')); + + if (empty($ids)) { + return; + } + + $ids = array_map('intval', $ids); + $query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')'); + break; + + case '#__fields': + // Only delete custom fields scoped to com_content.article + $query->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); + break; + + case '#__fields_values': + // Delete all field values — they are article-scoped + break; + + case '#__fields_categories': + // Delete field-category mappings for com_content.article fields only + $prefix = $db->getPrefix(); + $fTable = $prefix . 'fields'; + + $subQuery = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName($fTable)) + ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); + + $query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); + break; + // #__content and #__content_frontpage are fully owned by com_content default: break; @@ -303,6 +341,10 @@ class SnapshotRestoreEngine $tables[] = '#__content_frontpage'; $tables[] = '#__workflow_associations'; $tables[] = '#__contentitem_tag_map'; + $tables[] = '#__tags'; + $tables[] = '#__fields'; + $tables[] = '#__fields_values'; + $tables[] = '#__fields_categories'; } if (in_array('categories', $types)) { -- 2.52.0 From 8b6e260b28cfc16d0baca0147900d2358765d4ee Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 21 Jun 2026 19:09:20 -0500 Subject: [PATCH 3/4] fix: graceful degradation when remote upload fails (#66) Remote upload failure (S3/FTP/GDrive) no longer marks the entire backup as failed. The local archive is preserved with status 'complete' and the upload failure is logged as a warning. Applies to both BackupEngine and SteppedBackupEngine. Closes #66 --- CHANGELOG.md | 7 ++ .../src/Engine/BackupEngine.php | 41 ++++++---- .../src/Engine/SteppedBackupEngine.php | 77 +++++++++++-------- 3 files changed, 82 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9803a10..f7adef3 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 fb09db5..e45aa94 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 6ba97ea..e6a0f36 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()); -- 2.52.0 From ad1c0cf34908a735f5fa9674ede8257ea92bae41 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 21 Jun 2026 19:32:23 -0500 Subject: [PATCH 4/4] fix: scope #__fields_values dump and restore to com_content.article The fields_values table is shared across all Joomla extensions. Previously, dump captured ALL field values and restore deleted ALL field values, destroying data for contacts, users, and other extensions. Now scoped via subquery on field_id WHERE context = 'com_content.article'. --- .../src/Engine/SnapshotEngine.php | 26 +++++++++++++++++-- .../src/Engine/SnapshotRestoreEngine.php | 10 ++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php index f47b045..b581e63 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php @@ -128,8 +128,8 @@ class SnapshotEngine $data['tables']['#__fields'] = $rows; $this->log(' #__fields: ' . count($rows) . ' rows'); - // Field values — dump all (small, article-scoped) - $rows = $this->dumpTable($db, str_replace('#__', $prefix, '#__fields_values'), '#__fields_values', 'articles'); + // Field values — only for com_content.article fields (table is shared across extensions) + $rows = $this->dumpFieldValues($db, $prefix); $data['tables']['#__fields_values'] = $rows; $this->log(' #__fields_values: ' . count($rows) . ' rows'); @@ -266,6 +266,28 @@ class SnapshotEngine * * Uses a subquery: field_id IN (SELECT id FROM #__fields WHERE context = 'com_content.article') */ + /** + * Dump field values only for com_content.article fields. + */ + private function dumpFieldValues(object $db, string $prefix): array + { + $fvTable = $prefix . 'fields_values'; + $fTable = $prefix . 'fields'; + + $subQuery = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName($fTable)) + ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName($fvTable)) + ->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + private function dumpFieldCategories(object $db, string $prefix): array { $fcTable = $prefix . 'fields_categories'; diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index 665c301..917d276 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -304,7 +304,15 @@ class SnapshotRestoreEngine break; case '#__fields_values': - // Delete all field values — they are article-scoped + // Only delete field values for com_content.article fields + $prefix = $db->getPrefix(); + $fTable = $prefix . 'fields'; + + $subQuery = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName($fTable)) + ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); + $query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); break; case '#__fields_categories': -- 2.52.0