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();