Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| affe2db36a | |||
| 0c4ba5756b | |||
| 896c4c597e | |||
| a77458a24a | |||
| 64c6ef6d7e | |||
| b2630fbc87 | |||
| 2e7e49fa60 | |||
| 55954ba081 | |||
| 7ecc855e40 | |||
| a4c03d0032 | |||
| 682538e4de | |||
| b2874f32f2 | |||
| 937e38bcc6 | |||
| b3e7c8ec72 | |||
| 9656a2a92b |
@@ -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.22.06-dev</version>
|
||||
<version>01.23.01-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.22.06
|
||||
# VERSION: 01.23.01
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+2
-21
@@ -1,6 +1,8 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [01.23.00] --- 2026-06-18
|
||||
|
||||
## [01.21.00] --- 2026-06-16
|
||||
|
||||
### Fixed
|
||||
@@ -15,24 +17,3 @@
|
||||
## [01.07.00] --- 2026-06-07
|
||||
|
||||
## [01.06.00] --- 2026-06-07
|
||||
|
||||
|
||||
## [01.05.00] --- 2026-06-07
|
||||
|
||||
### Added
|
||||
- Dashboard submenu entry as default landing page with `class:home` icon
|
||||
- `[DEFAULT_DIR]` placeholder for portable backup directory configuration — resolves to `administrator/components/com_mokosuitebackup/backups` at runtime
|
||||
- Live AJAX directory validation on backup_dir field — checks existence, writability, and placeholder resolution as user types (debounced 400ms)
|
||||
- `checkDir` AJAX endpoint for real-time directory permission checking
|
||||
- Web-accessible warning badge on backup download buttons when archive is inside web root
|
||||
- Inline security warning in FolderPicker when default directory is selected
|
||||
- Auto `.htaccess` and `index.html` protection for web-accessible backup directories on profile save and at backup time
|
||||
- Font Awesome 6 submenu icons via CSS injection in `MokoSuiteBackupComponent::boot()`
|
||||
- `syncMenuIcons()` installer postflight — syncs icon classes to `#__menu` on install and update
|
||||
- `encryptionPassword` property on `SteppedSession` for upcoming stepped backup encryption support
|
||||
|
||||
### Changed
|
||||
- Profile `backup_dir` default changed from literal path to `[DEFAULT_DIR]` placeholder
|
||||
- Backup engine fallback directory changed from hardcoded path to `[DEFAULT_DIR]`
|
||||
- `isUsingDefaultBackupDir()` now matches `[DEFAULT_DIR]` placeholder in addition to literal path and empty values
|
||||
- Dashboard submenu language key added to `.sys.ini` files (en-GB, en-US)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoSuiteBackup
|
||||
|
||||
<!-- VERSION: 01.22.06 -->
|
||||
<!-- VERSION: 01.23.01 -->
|
||||
|
||||
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.22.06-dev</version>
|
||||
<version>01.23.01-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.23.01-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -305,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');
|
||||
@@ -487,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;
|
||||
}
|
||||
|
||||
@@ -434,12 +434,14 @@ class SteppedBackupEngine
|
||||
}
|
||||
|
||||
$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'),
|
||||
'total_size' => $totalSize,
|
||||
'checksum' => $checksum,
|
||||
'log' => $logContent,
|
||||
];
|
||||
|
||||
@@ -471,7 +473,7 @@ class SteppedBackupEngine
|
||||
|
||||
NotificationSender::send($profile, $record, true, $logContent);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -529,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();
|
||||
|
||||
@@ -543,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
|
||||
@@ -580,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) . ');';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage com_mokosuitebackup
|
||||
* @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Utility;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
|
||||
/**
|
||||
* Provides a public API for external plugins (e.g. MokoSuiteClient bridge)
|
||||
* to query backup status without depending on internal model internals.
|
||||
*
|
||||
* @since 01.22.07
|
||||
*/
|
||||
class BackupStatusHelper
|
||||
{
|
||||
/**
|
||||
* Get a summary of the latest backup status.
|
||||
*
|
||||
* Returns an array suitable for inclusion in heartbeat payloads.
|
||||
*
|
||||
* @param int $staleDays Days without backup before status is degraded.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getStatus(int $staleDays = 7): array
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return ['installed' => true, 'status' => 'error', 'message' => 'Database unavailable'];
|
||||
}
|
||||
|
||||
// Most recently inserted backup record (by ID, any status)
|
||||
$query = $db->getQuery(true)
|
||||
->select([
|
||||
$db->quoteName('id'),
|
||||
$db->quoteName('description'),
|
||||
$db->quoteName('status'),
|
||||
$db->quoteName('backup_type'),
|
||||
$db->quoteName('total_size'),
|
||||
$db->quoteName('backupstart'),
|
||||
$db->quoteName('backupend'),
|
||||
$db->quoteName('origin'),
|
||||
$db->quoteName('filesexist'),
|
||||
])
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->order($db->quoteName('id') . ' DESC');
|
||||
|
||||
$db->setQuery($query, 0, 1);
|
||||
$latest = $db->loadObject();
|
||||
|
||||
if (!$latest)
|
||||
{
|
||||
return [
|
||||
'installed' => true,
|
||||
'status' => 'degraded',
|
||||
'message' => 'No backups found',
|
||||
];
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
);
|
||||
$totalBackups = (int) $db->loadResult();
|
||||
|
||||
$cutoff = date('Y-m-d H:i:s', strtotime("-{$staleDays} days"));
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff))
|
||||
);
|
||||
$recentBackups = (int) $db->loadResult();
|
||||
|
||||
// Failures in last 7 days
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
|
||||
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff))
|
||||
);
|
||||
$failCount7d = (int) $db->loadResult();
|
||||
|
||||
// Determine overall status
|
||||
$daysSince = 999;
|
||||
|
||||
if (!empty($latest->backupstart) && $latest->backupstart !== '0000-00-00 00:00:00')
|
||||
{
|
||||
$daysSince = (int) ((time() - strtotime($latest->backupstart)) / 86400);
|
||||
}
|
||||
|
||||
$status = 'ok';
|
||||
|
||||
if ($latest->status === 'fail')
|
||||
{
|
||||
$status = 'degraded';
|
||||
}
|
||||
elseif ($latest->status !== 'complete')
|
||||
{
|
||||
$status = ($latest->status === 'running') ? 'ok' : 'degraded';
|
||||
}
|
||||
elseif ($daysSince > $staleDays)
|
||||
{
|
||||
$status = 'degraded';
|
||||
}
|
||||
|
||||
$sizeMb = $latest->total_size
|
||||
? round($latest->total_size / 1048576)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'installed' => true,
|
||||
'status' => $status,
|
||||
'last_backup' => $latest->backupstart,
|
||||
'last_status' => $latest->status,
|
||||
'last_size_mb' => $sizeMb,
|
||||
'days_since' => $daysSince,
|
||||
'backup_type' => $latest->backup_type,
|
||||
'origin' => $latest->origin,
|
||||
'total_backups' => $totalBackups,
|
||||
'recent_7d' => $recentBackups,
|
||||
'fail_count_7d' => $failCount7d,
|
||||
'files_exist' => (bool) $latest->filesexist,
|
||||
'description' => $latest->description,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MokoSuiteBackup component is installed.
|
||||
*
|
||||
* Useful for external plugins that want to check before calling getStatus().
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isInstalled(): bool
|
||||
{
|
||||
try
|
||||
{
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__extensions'))
|
||||
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
|
||||
|
||||
$db->setQuery($query);
|
||||
|
||||
return (int) $db->loadResult() > 0;
|
||||
}
|
||||
catch (\Throwable $e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.22.06-dev</version>
|
||||
<version>01.23.01-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.22.06-dev</version>
|
||||
<version>01.23.01-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.22.06-dev</version>
|
||||
<version>01.23.01-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.22.06-dev</version>
|
||||
<version>01.23.01-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.22.06-dev</version>
|
||||
<version>01.23.01-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -138,6 +138,15 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
* A profile value of 0 means "use the global default".
|
||||
*/
|
||||
private function cleanupOldBackups(): void
|
||||
{
|
||||
try {
|
||||
$this->doCleanup();
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoSuiteBackup: cleanupOldBackups() failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function doCleanup(): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$globalMaxAge = (int) ComponentHelper::getParams('com_mokosuitebackup')->get('max_age_days', 30);
|
||||
@@ -219,10 +228,11 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
|
||||
if (!@unlink($record->absolute_path)) {
|
||||
return; // Don't delete DB record if file can't be removed
|
||||
error_log('MokoSuiteBackup: Could not delete backup file (id=' . $record->id . '): ' . $record->absolute_path);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Also remove the log file if it exists alongside the archive
|
||||
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
|
||||
|
||||
if (is_file($logPath)) {
|
||||
@@ -230,12 +240,16 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
||||
}
|
||||
}
|
||||
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->delete($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $record->id)
|
||||
);
|
||||
$db->execute();
|
||||
try {
|
||||
$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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,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.22.06-dev</version>
|
||||
<version>01.23.01-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.22.06-dev</version>
|
||||
<version>01.23.01-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.22.06-dev</version>
|
||||
<version>01.23.01-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user