fix: enforce per-profile retention and repair Purge Old Backups button
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Project CI / Lint & Validate (pull_request) Successful in 12s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 9s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 39s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 30s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 4m0s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled

Two related backup-management fixes.

Retention (records/days to keep):
- The retention fieldset was defined in profile.xml but never rendered
  in the profile editor, so retention_days/retention_count were invisible.
  Render the retention fieldset on the Archive tab.
- retention_days/retention_count were read by nothing, so they pruned no
  backups. Add RetentionManager::prune(), called from completeRecord() in
  both BackupEngine and SteppedBackupEngine after a backup finishes.
  Policy: delete a completed/warning backup when EITHER it is older than
  retention_days OR it falls outside the newest retention_count copies
  (0 = unlimited for that rule). Deleting a record also removes its
  archive and log file.
- Correct misleading language/schema text ("use global default" — no such
  global backup-retention setting exists) to "0 = unlimited".

Purge Old Backups button:
- The modal only opened via a fragile selector match on the toolbar
  button's inline onclick, which Joomla 6's Atum toolbar does not render,
  so the button did nothing. Wrap Joomla.submitbutton to open the modal
  for the backups.purgeModal task, keeping the selector as a fallback.
This commit is contained in:
2026-07-04 13:43:58 -05:00
parent 2e5b57fe5d
commit 10248b284a
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'); ?>