diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index c33824a..f07f35e 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -5,7 +5,7 @@ Package - MokoSuiteBackup MokoConsulting Full-site backup and restore for Joomla — database, files, and configuration - 01.21.00-dev + 01.22.10-dev GNU General Public License v3 diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 4b7f98e..d14d951 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -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" diff --git a/README.md b/README.md index 10f3982..6d0bb5a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoSuiteBackup - + Full-site backup and restore for Joomla — database, files, and configuration. diff --git a/mokosuitebackup.xml b/mokosuitebackup.xml index 2a0373d..627d7dd 100644 --- a/mokosuitebackup.xml +++ b/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Web Services - MokoSuiteBackup - 01.21.00 + 01.22.10-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/com_mokosuitebackup/forms/profile.xml b/source/packages/com_mokosuitebackup/forms/profile.xml index 571dbc4..d01599f 100644 --- a/source/packages/com_mokosuitebackup/forms/profile.xml +++ b/source/packages/com_mokosuitebackup/forms/profile.xml @@ -176,6 +176,29 @@ +
+ + +
+
MokoSuiteBackup - 01.21.00 + 01.22.10-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/com_mokosuitebackup/sql/install.mysql.sql b/source/packages/com_mokosuitebackup/sql/install.mysql.sql index 8929203..70936dc 100644 --- a/source/packages/com_mokosuitebackup/sql/install.mysql.sql +++ b/source/packages/com_mokosuitebackup/sql/install.mysql.sql @@ -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)', diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.21.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.21.00.sql new file mode 100644 index 0000000..41cd9c6 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.21.00.sql @@ -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`; diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index bf6544d..626073c 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -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; } diff --git a/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php b/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php index bf49ec7..4beb213 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php +++ b/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php @@ -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) . ');'; } diff --git a/source/packages/com_mokosuitebackup/src/Engine/DatabaseImporter.php b/source/packages/com_mokosuitebackup/src/Engine/DatabaseImporter.php index 717dce8..af681d5 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/DatabaseImporter.php +++ b/source/packages/com_mokosuitebackup/src/Engine/DatabaseImporter.php @@ -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(); diff --git a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php index 30f1944..4c2f63f 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php +++ b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php @@ -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 = "\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
7Complete
+ +
+

Security Verification

+

To prevent unauthorized access, enter the security code from the file .mokorestore-security.php in your site root.

+
+
+ 🔒 How to find the code +
+
    +
  1. Connect to your server via FTP, SSH, or file manager
  2. +
  3. Open .mokorestore-security.php in the site root directory
  4. +
  5. Copy the 8-character code and enter it below
  6. +
+
+
+ + +
+
+
+ + +
+
+ -
+

Pre-Installation Checks

Verify your server meets the requirements for Joomla and MokoRestore.

    @@ -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); diff --git a/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php b/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php index 26467af..a5631fd 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php +++ b/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php @@ -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; } diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index 819285d..7df30e7 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -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) . ');'; } diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index 0aaa9f5..eebb683 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -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; ?> - diff --git a/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml index de000b4..fe8df48 100644 --- a/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Action Log - MokoSuiteBackup - 01.21.00 + 01.22.10-dev 2026-06-04 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml index 98d147a..f9f2d29 100644 --- a/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Console - MokoSuiteBackup - 01.21.00 + 01.22.10-dev 2026-06-04 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml index 9780208..2b33be7 100644 --- a/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Content - MokoSuiteBackup - 01.21.00 + 01.22.10-dev 2026-06-04 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml index 5532044..5f66056 100644 --- a/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml @@ -1,7 +1,7 @@ Quick Icon - MokoSuiteBackup - 01.21.00 + 01.22.10-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml index 4b19018..6480eeb 100644 --- a/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> System - MokoSuiteBackup - 01.21.00 + 01.22.10-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php b/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php index b0d8d38..e3d00c8 100644 --- a/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php +++ b/source/packages/plg_system_mokosuitebackup/src/Extension/MokoSuiteBackup.php @@ -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(), diff --git a/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml index 3402da0..37d715d 100644 --- a/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Task - MokoSuiteBackup - 01.21.00 + 01.22.10-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml index 2a0373d..627d7dd 100644 --- a/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Web Services - MokoSuiteBackup - 01.21.00 + 01.22.10-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/pkg_mokosuitebackup.xml b/source/pkg_mokosuitebackup.xml index 14499be..b09d253 100644 --- a/source/pkg_mokosuitebackup.xml +++ b/source/pkg_mokosuitebackup.xml @@ -8,7 +8,7 @@ Package - MokoSuiteBackup mokosuitebackup - 01.21.00 + 01.22.10-dev 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/script.php b/source/script.php index f194511..4964a31 100644 --- a/source/script.php +++ b/source/script.php @@ -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( + 'MokoSuiteBackup — Missing PHP Extensions: ' + . 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();