feat: per-profile retention, ntfy notifications, extension checks #46
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -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">🔒</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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user