3 Commits

Author SHA1 Message Date
gitea-actions[bot] 9a908e2e3c chore(version): pre-release bump to 01.22.00-rc [skip ci] 2026-06-17 07:57:03 +00:00
gitea-actions[bot] d8367d7beb chore(version): pre-release bump to 01.21.01-dev [skip ci] 2026-06-17 07:56:07 +00:00
Jonathan Miller 11141f27f4 feat: per-profile backup retention (days and count)
Generic: Project CI / Tests (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Generic: Project CI / Lint & Validate (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 6s
Each profile can now set its own retention_days and retention_count.
A value of 0 means use the global default from component options.

Cleanup logic refactored to iterate per-profile with individual
retention thresholds. Also cleans up orphaned records where the
parent profile was deleted. Log files alongside archives are now
removed during cleanup.

Extracted deleteBackupRecord() helper for consistent file+DB cleanup.
2026-06-17 02:55:55 -05:00
18 changed files with 138 additions and 65 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<display-name>Package - MokoSuiteBackup</display-name>
<org>MokoConsulting</org>
<description>Full-site backup and restore for Joomla — database, files, and configuration</description>
<version>01.21.00-dev</version>
<version>01.22.00-dev</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
# VERSION: 01.21.00
# VERSION: 01.22.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.21.00 -->
<!-- VERSION: 01.22.00 -->
Full-site backup and restore for Joomla — database, files, and configuration.
+1 -1
View File
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.00-rc</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -176,6 +176,29 @@
</field>
</fieldset>
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
<field
name="retention_days"
type="number"
label="COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS"
description="COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC"
default="0"
min="0"
max="365"
hint="0"
/>
<field
name="retention_count"
type="number"
label="COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT"
description="COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC"
default="0"
min="0"
max="999"
hint="0"
/>
</fieldset>
<fieldset name="notifications" label="COM_MOKOJOOMBACKUP_FIELDSET_NOTIFICATIONS">
<field
name="notify_email"
@@ -197,6 +197,13 @@ COM_MOKOJOOMBACKUP_FIELD_NOTIFY_SUCCESS="Notify on Success"
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_SUCCESS_DESC="Send an email when a backup completes successfully."
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE="Notify on Failure"
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE_DESC="Send an email when a backup fails. Includes log excerpt for debugging."
; 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_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_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"
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC_DESC="The ntfy topic to publish notifications to. Leave blank to disable push notifications."
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.00-rc</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -36,6 +36,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',
`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)',
@@ -0,0 +1,4 @@
-- Add per-profile retention settings
ALTER TABLE `#__mokosuitebackup_profiles`
ADD COLUMN `retention_days` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = use global default' AFTER `notify_on_failure`,
ADD COLUMN `retention_count` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = use global default' AFTER `retention_days`;
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.00-rc</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.00-rc</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.00-rc</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.00-rc</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.00-rc</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -133,72 +133,109 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
}
/**
* Remove backup records and files older than max_age_days or exceeding max_backups.
* Remove backup records and files per profile retention settings.
* Each profile can override the global max_age_days and max_backups.
* A profile value of 0 means "use the global default".
*/
private function cleanupOldBackups(): void
{
$db = Factory::getDbo();
$maxAge = (int) $this->params->get('max_age_days', 30);
$maxBackups = (int) $this->params->get('max_backups', 10);
$db = Factory::getDbo();
$globalMaxAge = (int) ComponentHelper::getParams('com_mokosuitebackup')->get('max_age_days', 30);
$globalMaxCount = (int) ComponentHelper::getParams('com_mokosuitebackup')->get('max_backups', 10);
// Delete by age
$cutoff = date('Y-m-d H:i:s', strtotime("-{$maxAge} days"));
$query = $db->getQuery(true)
->select('id, absolute_path')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$expired = $db->loadObjectList();
foreach ($expired as $record) {
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
if (!@unlink($record->absolute_path)) {
continue; // Don't delete DB record if file can't be removed
}
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
}
// Enforce max backups count (keep newest)
// Load all published profiles with their retention settings
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
->select([$db->quoteName('id'), $db->quoteName('retention_days'), $db->quoteName('retention_count')])
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('published') . ' = 1');
$db->setQuery($query);
$totalCount = (int) $db->loadResult();
$profiles = $db->loadObjectList();
if ($totalCount > $maxBackups) {
$excess = $totalCount - $maxBackups;
foreach ($profiles as $profile) {
$maxAge = (int) $profile->retention_days > 0 ? (int) $profile->retention_days : $globalMaxAge;
$maxCount = (int) $profile->retention_count > 0 ? (int) $profile->retention_count : $globalMaxCount;
$pid = (int) $profile->id;
// Delete by age for this profile
$cutoff = date('Y-m-d H:i:s', strtotime("-{$maxAge} days"));
$query = $db->getQuery(true)
->select('id, absolute_path')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->order($db->quoteName('backupstart') . ' ASC');
$db->setQuery($query, 0, $excess);
$oldest = $db->loadObjectList();
->where($db->quoteName('profile_id') . ' = ' . $pid)
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$expired = $db->loadObjectList();
foreach ($oldest as $record) {
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
if (!@unlink($record->absolute_path)) {
continue; // Do not delete DB record if file cannot be removed
}
}
foreach ($expired as $record) {
$this->deleteBackupRecord($db, $record);
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
// Enforce max count for this profile (keep newest)
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . $pid)
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$totalCount = (int) $db->loadResult();
if ($totalCount > $maxCount) {
$excess = $totalCount - $maxCount;
$query = $db->getQuery(true)
->select('id, absolute_path')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . $pid)
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->order($db->quoteName('backupstart') . ' ASC');
$db->setQuery($query, 0, $excess);
$oldest = $db->loadObjectList();
foreach ($oldest as $record) {
$this->deleteBackupRecord($db, $record);
}
}
}
// Also clean up orphaned records (profile deleted but records remain)
$query = $db->getQuery(true)
->select('r.id, r.absolute_path')
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
->where('p.id IS NULL')
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$orphans = $db->loadObjectList();
foreach ($orphans as $record) {
$this->deleteBackupRecord($db, $record);
}
}
/**
* Delete a backup record and its archive file.
*/
private function deleteBackupRecord(object $db, object $record): void
{
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
if (!@unlink($record->absolute_path)) {
return; // Don't delete DB record if file can't be removed
}
// Also remove the log file if it exists alongside the archive
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
if (is_file($logPath)) {
@unlink($logPath);
}
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
}
/**
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.00-rc</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.00-rc</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.21.00</version>
<version>01.22.00-rc</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>