feat: per-profile retention, ntfy notifications, extension checks #46

Merged
jmiller merged 20 commits from dev into main 2026-06-18 16:07:21 +00:00
26 changed files with 423 additions and 112 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.10-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.10
# 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.10 -->
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.10-dev</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.10-dev</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`;
@@ -267,10 +267,15 @@ class BackupEngine
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
}
// Final record update
// Final record update (includes fields needed by NotificationSender)
$update = (object) [
'id' => $recordId,
'status' => 'complete',
'description' => $description,
'backup_type' => $profile->backup_type,
'archivename' => $archiveName,
'origin' => $origin,
'backupstart' => $now,
'total_size' => $totalSize,
'db_size' => $dbSize,
'files_count' => $filesCount,
@@ -300,15 +305,19 @@ class BackupEngine
$this->log('FATAL: ' . $e->getMessage());
$update = (object) [
'id' => $recordId,
'status' => 'fail',
'description' => $description ?: '',
'backup_type' => $profile->backup_type ?? 'full',
'origin' => $origin,
'archivename' => $archiveName,
'backupstart' => $now ?? date('Y-m-d H:i:s'),
'backupend' => date('Y-m-d H:i:s'),
'log' => implode("\n", $this->log),
'id' => $recordId,
'status' => 'fail',
'description' => $description ?: '',
'backup_type' => $profile->backup_type ?? 'full',
'origin' => $origin,
'archivename' => $archiveName,
'backupstart' => $now ?? date('Y-m-d H:i:s'),
'backupend' => date('Y-m-d H:i:s'),
'total_size' => 0,
'files_count' => 0,
'tables_count' => 0,
'remote_filename' => '',
'log' => implode("\n", $this->log),
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
@@ -381,14 +390,19 @@ class BackupEngine
*/
private function checkRequiredExtensions(): true|string
{
$required = [
'zip' => 'ext-zip (required for archive creation)',
'pdo' => 'ext-pdo (required for database operations)',
'pdo_mysql' => 'ext-pdo_mysql (required for MySQL database dumps)',
'mbstring' => 'ext-mbstring (required for binary-safe operations)',
];
$missing = [];
if (!extension_loaded('zip')) {
$missing[] = 'ext-zip (required for archive creation)';
}
if (!extension_loaded('mbstring') && !function_exists('mb_strlen')) {
$missing[] = 'ext-mbstring (required for binary-safe operations)';
foreach ($required as $ext => $label) {
if (!extension_loaded($ext)) {
$missing[] = $label;
}
}
if (!empty($missing)) {
@@ -477,6 +491,7 @@ class BackupEngine
$name = $zip->getNameIndex($i);
if ($name === false) {
$this->log('WARNING: Could not read file at index ' . $i . ' during encryption — file may remain unencrypted');
continue;
}
@@ -60,7 +60,9 @@ class DatabaseDumper
$output[] = '-- Generated: ' . date('Y-m-d H:i:s');
$output[] = '-- Server: ' . $db->getServerType();
$output[] = '-- Database: ' . $db->getName();
$output[] = '-- Prefix: ' . $prefix;
$output[] = '-- Original Prefix: ' . $prefix;
$output[] = '-- Abstract Prefix: #__';
$output[] = '-- Note: Table names use #__ placeholder. Replace with your prefix on restore.';
$output[] = '';
$output[] = 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";';
$output[] = 'SET time_zone = "+00:00";';
@@ -90,7 +92,7 @@ class DatabaseDumper
$this->tablesCount++;
$output[] = '-- --------------------------------------------------------';
$output[] = '-- Table: ' . $table;
$output[] = '-- Table: ' . $abstractName;
if ($skipData) {
$output[] = '-- (data excluded)';
@@ -112,8 +114,11 @@ class DatabaseDumper
continue;
}
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
$output[] = $createRow[1] . ';';
// Replace all occurrences of the live prefix with #__ in CREATE TABLE
// output — covers the table itself and FK REFERENCES to other tables
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
$output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;';
$output[] = $createSql . ';';
$output[] = '';
}
@@ -160,7 +165,7 @@ class DatabaseDumper
}
$columns = array_map([$db, 'quoteName'], array_keys($row));
$output[] = 'INSERT INTO ' . $db->quoteName($table)
$output[] = 'INSERT INTO `' . $abstractName . '`'
. ' (' . implode(', ', $columns) . ')'
. ' VALUES (' . implode(', ', $values) . ');';
}
@@ -87,11 +87,8 @@ class DatabaseImporter
continue;
}
// Replace the prefix from the dump with the current site prefix.
// The dump uses real table names (with the original prefix), but
// if restoring to a site with a different prefix we need to handle it.
// Our DatabaseDumper uses real names, so no replacement needed
// for same-site restores.
// Replace abstract #__ prefix with the current site's prefix
$statement = str_replace('#__', $prefix, $statement);
try {
$db->setQuery($statement);
@@ -110,6 +107,8 @@ class DatabaseImporter
$remaining = trim($currentStatement);
if (!empty($remaining)) {
$remaining = str_replace('#__', $prefix, $remaining);
try {
$db->setQuery($remaining);
$db->execute();
@@ -109,6 +109,56 @@ if (empty($_SESSION['restore_token'])) {
$token = $_SESSION['restore_token'];
// ── Security Verification ───────────────────────────────────────────
// Write a security file to the web root with a random code.
// The user must read the code from the file and enter it in the browser
// to prove they have filesystem access before any restore actions are allowed.
$securityFile = RESTORE_DIR . '/.mokorestore-security.php';
$securityCode = $_SESSION['security_code'] ?? '';
if (empty($securityCode)) {
$securityCode = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
$_SESSION['security_code'] = $securityCode;
$_SESSION['security_verified'] = false;
// Write security file with the code
$securityContent = "<?php die('MokoRestore Security Code: " . $securityCode . "'); ?>\n"
. "MokoRestore Security Verification\n"
. "==================================\n"
. "Code: " . $securityCode . "\n"
. "Enter this code in the MokoRestore browser interface to proceed.\n"
. "This file will be deleted automatically after verification.\n";
if (file_put_contents($securityFile, $securityContent) === false) {
// Cannot write security file — skip verification to avoid locking user out
$_SESSION['security_verified'] = true;
error_log('MokoRestore: Cannot write security file — verification skipped (check directory permissions)');
}
}
// Handle security code verification via POST
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'verify_security') {
header('Content-Type: application/json; charset=utf-8');
$inputCode = strtoupper(trim($_POST['security_code'] ?? ''));
if ($inputCode === $securityCode) {
$_SESSION['security_verified'] = true;
// Delete the security file
if (is_file($securityFile)) {
@unlink($securityFile);
}
echo json_encode(['success' => true, 'message' => 'Security verified']);
} else {
echo json_encode(['success' => false, 'message' => 'Incorrect security code. Check the file: .mokorestore-security.php']);
}
exit;
}
// Block all other actions until security is verified
$securityVerified = !empty($_SESSION['security_verified']);
// ── AJAX Handler ────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json; charset=utf-8');
@@ -118,6 +168,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
exit;
}
if (!$securityVerified) {
echo json_encode(['success' => false, 'message' => 'Security verification required. Enter the code from .mokorestore-security.php']);
exit;
}
@set_time_limit(0);
@ini_set('max_execution_time', '0');
@ini_set('memory_limit', '512M');
@@ -348,7 +403,12 @@ function actionDatabase(array $data): array
$pdo->exec("SET time_zone = '+00:00'");
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
$sql = file_get_contents($sqlFile);
$sql = file_get_contents($sqlFile);
$prefix = getValidatedPrefix($data);
// Replace abstract #__ prefix with the user's target prefix
$sql = str_replace('#__', $prefix, $sql);
$parts = explode(";\n", $sql);
$statements = 0;
$errors = 0;
@@ -675,7 +735,7 @@ HTACCESS;
function getValidatedPrefix(array $data): string
{
$prefix = getValidatedPrefix($data);
$prefix = trim($data['db_prefix'] ?? 'moko_');
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) {
throw new RuntimeException('Invalid table prefix format');
@@ -710,7 +770,7 @@ function actionListAdmins(array $data): array
function actionResetAdmin(array $data): array
{
$pdo = getDbConnection($data);
$prefix = $data['db_prefix'] ?? 'moko_';
$prefix = getValidatedPrefix($data);
$userId = (int) ($data['admin_id'] ?? 0);
$password = $data['new_password'] ?? '';
@@ -981,8 +1041,33 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Complete</div>
</div>
<!-- Step 0: Security Verification -->
<div class="mr-panel <?php echo $securityVerified ? '' : 'visible'; ?>" id="panel0">
<h2>Security Verification</h2>
<p class="mr-desc">To prevent unauthorized access, enter the security code from the file <code>.mokorestore-security.php</code> in your site root.</p>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#128274;</span> How to find the code
</div>
<ol style="margin:0;padding-left:1.25rem;color:#475569;font-size:0.9rem;line-height:1.6">
<li>Connect to your server via FTP, SSH, or file manager</li>
<li>Open <code>.mokorestore-security.php</code> in the site root directory</li>
<li>Copy the 8-character code and enter it below</li>
</ol>
</div>
<div class="mr-field">
<label>Security Code</label>
<input type="text" id="securityCode" placeholder="e.g. A1B2C3D4" maxlength="8" style="text-transform:uppercase;letter-spacing:0.2em;font-family:monospace;font-size:1.1rem;text-align:center">
</div>
<div class="mr-status" id="securityStatus"></div>
<div class="mr-actions">
<span></span>
<button class="mr-btn mr-btn-primary" id="btnVerify" onclick="verifySecurity()">Verify &amp; Continue</button>
</div>
</div>
<!-- Step 1: Pre-flight Checks -->
<div class="mr-panel visible" id="panel1">
<div class="mr-panel <?php echo $securityVerified ? 'visible' : ''; ?>" id="panel1">
<h2>Pre-Installation Checks</h2>
<p class="mr-desc">Verify your server meets the requirements for Joomla and MokoRestore.</p>
<ul class="mr-checks" id="checkList"></ul>
@@ -1223,6 +1308,35 @@ function setBtnLoading(btn, loading) {
}
// Step 1
async function verifySecurity() {
const btn = document.getElementById('btnVerify');
setBtnLoading(btn, true);
const code = document.getElementById('securityCode').value.trim();
if (!code) {
setStatus('securityStatus', 'Please enter the security code', 'error');
setBtnLoading(btn, false);
return;
}
const form = new FormData();
form.append('action', 'verify_security');
form.append('security_code', code);
form.append('token', TOKEN);
const resp = await fetch('', { method: 'POST', body: form });
const r = await resp.json();
setBtnLoading(btn, false);
if (r.success) {
setStatus('securityStatus', 'Verified!', 'success');
document.getElementById('panel0').classList.remove('visible');
document.getElementById('panel1').classList.add('visible');
} else {
setStatus('securityStatus', r.message, 'error');
}
}
async function runPreflight() {
const btn = document.getElementById('btnCheck');
setBtnLoading(btn, true);
@@ -169,6 +169,12 @@ class NotificationSender
return false;
}
if (!function_exists('curl_init')) {
error_log('MokoSuiteBackup: ntfy notifications require ext-curl');
return false;
}
try {
$config = Factory::getApplication()->getConfig();
$siteName = $config->get('sitename', 'Joomla Site');
@@ -219,7 +225,7 @@ class NotificationSender
}
if ($httpCode < 200 || $httpCode >= 300) {
error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . $response);
error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . substr((string) $response, 0, 200));
return false;
}
@@ -220,8 +220,7 @@ class SteppedBackupEngine
$db = Factory::getDbo();
// Dump this single table
$dumper = new DatabaseDumper([]);
$sql = $this->dumpSingleTable($db, $table);
$sql = $this->dumpSingleTable($db, $table);
// Append to a temp SQL file that will be added to ZIP in finalize
$sqlFile = $session->archivePath . '.sql';
@@ -234,8 +233,9 @@ class SteppedBackupEngine
. "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n"
. "SET time_zone = \"+00:00\";\n\n";
if (file_put_contents($sqlFile, $header) === false) {
throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile);
}
throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile);
}
$flags = FILE_APPEND;
}
@@ -433,14 +433,49 @@ class SteppedBackupEngine
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
}
$totalSize = is_file($session->archivePath) ? filesize($session->archivePath) : 0;
$checksum = is_file($session->archivePath) ? hash_file('sha256', $session->archivePath) : '';
$update = (object) [
'id' => $session->recordId,
'status' => 'complete',
'backupend' => date('Y-m-d H:i:s'),
'log' => $logContent,
'id' => $session->recordId,
'status' => 'complete',
'backupend' => date('Y-m-d H:i:s'),
'total_size' => $totalSize,
'checksum' => $checksum,
'log' => $logContent,
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
// Send notifications (email + ntfy)
try {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . (int) $session->profileId);
$db->setQuery($query);
$profile = $db->loadObject();
if ($profile) {
$record = (object) [
'id' => $session->recordId,
'description' => $session->description ?? '',
'backup_type' => $session->backupType,
'archivename' => $session->archiveName,
'origin' => $session->origin,
'backupstart' => '',
'backupend' => date('Y-m-d H:i:s'),
'total_size' => $totalSize,
'files_count' => $session->filesCount ?? 0,
'tables_count' => $session->tablesCount ?? 0,
'remote_filename' => '',
];
NotificationSender::send($profile, $record, true, $logContent);
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
}
}
/**
@@ -448,15 +483,47 @@ class SteppedBackupEngine
*/
private function failRecord(SteppedSession $session, string $error): void
{
$db = Factory::getDbo();
$db = Factory::getDbo();
$logContent = implode("\n", $session->log);
$update = (object) [
'id' => $session->recordId,
'status' => 'fail',
'backupend' => date('Y-m-d H:i:s'),
'log' => implode("\n", $session->log),
'log' => $logContent,
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
// Send failure notification
try {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . (int) $session->profileId);
$db->setQuery($query);
$profile = $db->loadObject();
if ($profile) {
$record = (object) [
'id' => $session->recordId,
'description' => $session->description,
'backup_type' => $session->backupType,
'archivename' => $session->archiveName,
'origin' => $session->origin,
'backupstart' => '',
'backupend' => date('Y-m-d H:i:s'),
'total_size' => 0,
'files_count' => $session->filesCount,
'tables_count' => $session->tablesCount,
'remote_filename' => '',
];
NotificationSender::send($profile, $record, false, $logContent);
}
} catch (\Exception $e) {
error_log('MokoSuiteBackup: SteppedBackupEngine failure notification failed: ' . $e->getMessage());
}
}
/**
@@ -464,13 +531,16 @@ class SteppedBackupEngine
*/
private function dumpSingleTable(object $db, string $table): string
{
$prefix = $db->getPrefix();
$abstractName = '#__' . substr($table, strlen($prefix));
$output = [];
$output[] = '-- --------------------------------------------------------';
$output[] = '-- Table: ' . $table;
$output[] = '-- Table: ' . $abstractName;
$output[] = '-- --------------------------------------------------------';
$output[] = '';
// CREATE TABLE
// CREATE TABLE — replace live prefix with #__
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
$createRow = $db->loadRow();
@@ -478,8 +548,10 @@ class SteppedBackupEngine
return '';
}
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
$output[] = $createRow[1] . ';';
// Replace all occurrences of the live prefix — covers FK REFERENCES too
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
$output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;';
$output[] = $createSql . ';';
$output[] = '';
// Data in chunks
@@ -515,7 +587,7 @@ class SteppedBackupEngine
}
$columns = array_map([$db, 'quoteName'], array_keys($row));
$output[] = 'INSERT INTO ' . $db->quoteName($table)
$output[] = 'INSERT INTO `' . $abstractName . '`'
. ' (' . implode(', ', $columns) . ')'
. ' VALUES (' . implode(', ', $values) . ');';
}
@@ -145,7 +145,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
$isWebAccessible = !empty($item->absolute_path)
&& strpos(realpath($item->absolute_path) ?: $item->absolute_path, realpath(JPATH_ROOT) ?: JPATH_ROOT) === 0;
?>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.download&id=' . $item->id); ?>"
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.download&id=' . $item->id . '&' . Session::getFormToken() . '=1'); ?>"
class="btn btn-sm btn-outline-primary" title="<?php echo Text::_('COM_MOKOJOOMBACKUP_DOWNLOAD'); ?>">
<span class="icon-download"></span>
</a>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.10-dev</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.10-dev</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.10-dev</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.10-dev</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.10-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -133,71 +133,122 @@ 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);
try {
$this->doCleanup();
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: cleanupOldBackups() failed: ' . $e->getMessage());
}
}
// 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'));
private function doCleanup(): void
{
$db = Factory::getDbo();
$globalMaxAge = (int) ComponentHelper::getParams('com_mokosuitebackup')->get('max_age_days', 30);
$globalMaxCount = (int) ComponentHelper::getParams('com_mokosuitebackup')->get('max_backups', 10);
// Load all published profiles with their retention settings
$query = $db->getQuery(true)
->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);
$expired = $db->loadObjectList();
$profiles = $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
}
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('profile_id') . ' = ' . $pid)
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$expired = $db->loadObjectList();
foreach ($expired as $record) {
$this->deleteBackupRecord($db, $record);
}
// 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)) {
error_log('MokoSuiteBackup: Could not delete backup file (id=' . $record->id . '): ' . $record->absolute_path);
return;
}
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
if (is_file($logPath)) {
@unlink($logPath);
}
}
try {
$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)
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$totalCount = (int) $db->loadResult();
if ($totalCount > $maxBackups) {
$excess = $totalCount - $maxBackups;
$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();
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
}
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
}
} catch (\Exception $e) {
error_log('MokoSuiteBackup: Could not delete backup record ' . $record->id . ': ' . $e->getMessage());
}
}
@@ -254,7 +305,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
'warning'
);
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: ' . $description . ' failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup: ' . $description . ' failed — ' . $e->getMessage(),
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.21.00</version>
<version>01.22.10-dev</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.10-dev</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.10-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+13
View File
@@ -58,6 +58,19 @@ class Pkg_MokoSuiteBackupInstallerScript
return false;
}
// Check required PHP extensions (warn but don't block install)
$requiredExts = ['zip', 'pdo', 'pdo_mysql', 'mbstring', 'curl'];
$missingExts = array_filter($requiredExts, fn($ext) => !extension_loaded($ext));
if (!empty($missingExts)) {
Factory::getApplication()->enqueueMessage(
'<strong>MokoSuiteBackup — Missing PHP Extensions</strong>: '
. implode(', ', array_map(fn($e) => 'ext-' . $e, $missingExts))
. '. Some features (backup, restore, remote upload, notifications) may not work until these are enabled.',
'warning'
);
}
// Save download key before Joomla re-registers the update site
if ($type === 'update') {
$this->preflight_saveKey();