diff --git a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini index 46c7903..0e5e3c0 100644 --- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini @@ -251,9 +251,9 @@ COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE_DESC="Send an email when a backup fails. ; Retention COM_MOKOJOOMBACKUP_FIELDSET_RETENTION="Retention" COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS="Keep Backups (days)" -COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 to use the global default from component options." +COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 for unlimited (keep by age disabled)." COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT="Keep Backups (count)" -COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 to use the global default from component options." +COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 for unlimited (keep by count disabled)." COM_MOKOJOOMBACKUP_FIELD_NTFY_SPACER_DESC="Push Notifications (ntfy) — Send instant push notifications to your phone or desktop via ntfy.sh or a self-hosted ntfy server." COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC="ntfy Topic" 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 6a8d625..1cb697a 100644 --- a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini @@ -130,3 +130,10 @@ COM_MOKOJOOMBACKUP_STATUS_WARNING="Warning" ; 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." + +; Retention (per-profile) +COM_MOKOJOOMBACKUP_FIELDSET_RETENTION="Retention" +COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS="Keep Backups (days)" +COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 for unlimited (keep by age disabled)." +COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT="Keep Backups (count)" +COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 for unlimited (keep by count disabled)." diff --git a/source/packages/com_mokosuitebackup/sql/install.mysql.sql b/source/packages/com_mokosuitebackup/sql/install.mysql.sql index 705ebfd..d1c24aa 100644 --- a/source/packages/com_mokosuitebackup/sql/install.mysql.sql +++ b/source/packages/com_mokosuitebackup/sql/install.mysql.sql @@ -49,8 +49,8 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` ( `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs', `notify_on_success` TINYINT(1) NOT NULL DEFAULT 0, `notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1, - `retention_days` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = use global default', - `retention_count` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = use global default', + `retention_days` INT(11) NOT NULL DEFAULT 0 COMMENT 'Delete backups older than N days; 0 = unlimited', + `retention_count` INT(11) NOT NULL DEFAULT 0 COMMENT 'Keep newest N backups; 0 = unlimited', `ntfy_topic` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy topic name', `ntfy_server` VARCHAR(512) NOT NULL DEFAULT 'https://ntfy.sh' COMMENT 'ntfy server URL', `ntfy_token` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy access token (optional)', diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index 04b8f33..e1a818c 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -403,6 +403,17 @@ class BackupEngine NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log)); } + // Enforce per-profile retention (age and/or copy count). + try { + $pruned = RetentionManager::prune($db, $profile); + + if ($pruned > 0) { + $this->log('Retention: pruned ' . $pruned . ' old backup(s)'); + } + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: retention pass failed: ' . $e->getMessage()); + } + // Dispatch event for actionlog and other listeners $this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin); diff --git a/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php b/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php new file mode 100644 index 0000000..8a611ba --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php @@ -0,0 +1,118 @@ + + * @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\Engine; + +defined('_JEXEC') or die; + +use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; + +/** + * Enforces per-profile backup retention. + * + * A profile may cap retained backups by age (retention_days) and/or by + * number of copies (retention_count). A backup is pruned when EITHER rule + * matches: it is older than retention_days OR it falls outside the newest + * retention_count copies. Deleting a record also removes its archive and + * log file, mirroring the Backup table's delete(). + */ +final class RetentionManager +{ + /** + * Prune old backups for a profile according to its retention settings. + * + * Called after a backup completes. Only 'complete' and 'warning' records + * are considered — pending/running/failed records are never pruned here. + * + * @param object $db Database driver + * @param object $profile Profile row (needs id, retention_days, retention_count) + * + * @return int Number of backup records deleted + */ + public static function prune(object $db, object $profile): int + { + $days = (int) ($profile->retention_days ?? 0); + $count = (int) ($profile->retention_count ?? 0); + + // No retention configured — nothing to do. + if ($days <= 0 && $count <= 0) { + return 0; + } + + // Newest first, so the index is the copy's position from the top. + $query = $db->getQuery(true) + ->select($db->quoteName(['id', 'absolute_path', 'backupstart'])) + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) + ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')') + ->order($db->quoteName('backupstart') . ' DESC'); + $db->setQuery($query); + $records = $db->loadObjectList() ?: []; + + if (empty($records)) { + return 0; + } + + $cutoffTs = $days > 0 ? (time() - ($days * 86400)) : null; + $deleted = 0; + + foreach ($records as $index => $record) { + $tooOld = $cutoffTs !== null && strtotime((string) $record->backupstart) < $cutoffTs; + $overCount = $count > 0 && $index >= $count; + + // Delete-if-either: prune when age OR count rule is exceeded. + if (!$tooOld && !$overCount) { + continue; + } + + if (self::deleteRecord($db, $record)) { + $deleted++; + } + } + + return $deleted; + } + + /** + * Delete a single backup record and its on-disk archive + log file. + * + * The DB row is removed first; the files are only unlinked if that + * succeeds, so a failed delete never orphans the record from its files. + */ + private static function deleteRecord(object $db, object $record): bool + { + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('id') . ' = ' . (int) $record->id); + $db->setQuery($query); + + try { + $db->execute(); + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: retention could not delete record ' . $record->id . ': ' . $e->getMessage()); + + return false; + } + + $archivePath = (string) ($record->absolute_path ?? ''); + + if ($archivePath !== '' && is_file($archivePath)) { + @unlink($archivePath); + + $logPath = BackupDirectory::logPathFromArchive($archivePath); + + if (is_file($logPath)) { + @unlink($logPath); + } + } + + return true; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index ee640e7..b78eb19 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -686,6 +686,13 @@ class SteppedBackupEngine if ($uploadFailed) { NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent); } + + // Enforce per-profile retention (age and/or copy count). + $pruned = RetentionManager::prune($db, $profile); + + if ($pruned > 0) { + $session->log('Retention: pruned ' . $pruned . ' old backup(s)'); + } } } catch (\Throwable $e) { error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage()); diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index 5417461..53fe54c 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -684,19 +684,37 @@ $listDirn = $this->escape($this->state->get('list.direction')); var PURGE_TOKEN = ; var purgeCountTimer = null; - // Intercept Purge toolbar button to show the modal + // Reset modal state and show it. + function openPurgeModal() { + document.getElementById('mb-purge-date').value = ''; + document.getElementById('mb-purge-count-wrapper').style.display = 'none'; + document.getElementById('mb-purge-none-wrapper').style.display = 'none'; + document.getElementById('mb-purge-submit').disabled = true; + bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-purge-modal')).show(); + } + + // Primary: wrap Joomla.submitbutton so the Purge toolbar button opens the + // modal instead of submitting the no-op backups.purgeModal task. This is + // resilient to how the Atum toolbar renders the button markup. + if (window.Joomla && typeof Joomla.submitbutton === 'function') { + var origSubmitbutton = Joomla.submitbutton; + Joomla.submitbutton = function(task) { + if (task === 'backups.purgeModal') { + openPurgeModal(); + return false; + } + return origSubmitbutton.apply(this, arguments); + }; + } + document.addEventListener('DOMContentLoaded', function() { + // Fallback: if the button still exposes an inline onclick, bind directly. var purgeBtn = document.querySelector('[onclick*="backups.purgeModal"], .button-trash'); if (purgeBtn) { purgeBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); - // Reset modal state - document.getElementById('mb-purge-date').value = ''; - document.getElementById('mb-purge-count-wrapper').style.display = 'none'; - document.getElementById('mb-purge-none-wrapper').style.display = 'none'; - document.getElementById('mb-purge-submit').disabled = true; - bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-purge-modal')).show(); + openPurgeModal(); return false; }, true); } diff --git a/source/packages/com_mokosuitebackup/tmpl/profile/edit.php b/source/packages/com_mokosuitebackup/tmpl/profile/edit.php index 1f6e636..a19611e 100644 --- a/source/packages/com_mokosuitebackup/tmpl/profile/edit.php +++ b/source/packages/com_mokosuitebackup/tmpl/profile/edit.php @@ -42,6 +42,7 @@ $token = Session::getFormToken();
form->renderFieldset('archive'); ?> + form->renderFieldset('retention'); ?>