Enforce per-profile retention and repair Purge Old Backups button #203

Merged
jmiller merged 1 commits from fix/backup-retention-purge into main 2026-07-04 20:26:21 +00:00
8 changed files with 173 additions and 11 deletions
@@ -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="<strong>Push Notifications (ntfy)</strong> — Send instant push notifications to your phone or desktop via <a href='https://ntfy.sh' target='_blank'>ntfy.sh</a> or a self-hosted ntfy server."
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC="ntfy Topic"
@@ -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)."
@@ -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)',
@@ -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);
@@ -0,0 +1,118 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
}
}
@@ -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());
@@ -684,19 +684,37 @@ $listDirn = $this->escape($this->state->get('list.direction'));
var PURGE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
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);
}
@@ -42,6 +42,7 @@ $token = Session::getFormToken();
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderFieldset('archive'); ?>
<?php echo $this->form->renderFieldset('retention'); ?>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>