feat: add dashboard view and console, content, actionlog plugins (#24, #25, #26, #27)
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Joomla: Extension CI / Release Readiness Check (pull_request) Has been cancelled
Joomla: Extension CI / Lint & Validate (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Update Server / Update Server (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled

Dashboard view becomes the default landing page with status cards,
quick actions (backup now w/ profile selector), and system health
checks. Three new plugins round out the package:

- plg_console_mokobackup: CLI commands (run, list, profiles, restore, cleanup)
- plg_content_mokobackup: auto-backup before extension install/update
- plg_actionlog_mokobackup: logs backup and profile actions to User Action Logs

BackupEngine now dispatches onMokoBackupAfterRun for plugin listeners.
Package manifest and install script updated to include and auto-enable
the new plugins.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-04 11:20:35 -05:00
parent 25e06fd08c
commit c1521cb235
39 changed files with 1673 additions and 1 deletions
@@ -8,9 +8,24 @@ COM_MOKOBACKUP="MokoJoomBackup"
COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
; Submenu
COM_MOKOBACKUP_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles"
; Dashboard view
COM_MOKOBACKUP_DASHBOARD_TITLE="MokoJoomBackup Dashboard"
COM_MOKOBACKUP_DASHBOARD_LAST_BACKUP="Last Backup"
COM_MOKOBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
COM_MOKOBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
COM_MOKOBACKUP_DASHBOARD_NO_SCHEDULED="No tasks scheduled"
COM_MOKOBACKUP_DASHBOARD_TOTAL_BACKUPS="Total Backups"
COM_MOKOBACKUP_DASHBOARD_STORAGE="Storage Used"
COM_MOKOBACKUP_DASHBOARD_FAILURES_7D="%d failures (7 days)"
COM_MOKOBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions"
COM_MOKOBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
COM_MOKOBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
COM_MOKOBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
; Backups view
COM_MOKOBACKUP_BACKUPS_TITLE="Backup Records"
COM_MOKOBACKUP_BACKUPS_TABLE_CAPTION="Table of backup records"
@@ -6,8 +6,21 @@
COM_MOKOBACKUP="MokoJoomBackup"
COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
COM_MOKOBACKUP_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles"
COM_MOKOBACKUP_DASHBOARD_TITLE="MokoJoomBackup Dashboard"
COM_MOKOBACKUP_DASHBOARD_LAST_BACKUP="Last Backup"
COM_MOKOBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
COM_MOKOBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
COM_MOKOBACKUP_DASHBOARD_NO_SCHEDULED="No tasks scheduled"
COM_MOKOBACKUP_DASHBOARD_TOTAL_BACKUPS="Total Backups"
COM_MOKOBACKUP_DASHBOARD_STORAGE="Storage Used"
COM_MOKOBACKUP_DASHBOARD_FAILURES_7D="%d failures (7 days)"
COM_MOKOBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions"
COM_MOKOBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
COM_MOKOBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
COM_MOKOBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
COM_MOKOBACKUP_BACKUPS_TITLE="Backup Records"
COM_MOKOBACKUP_PROFILES_TITLE="Backup Profiles"
COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
@@ -40,6 +40,7 @@
<administration>
<menu img="class:archive">COM_MOKOBACKUP</menu>
<submenu>
<menu link="option=com_mokobackup&amp;view=dashboard" img="class:archive">COM_MOKOBACKUP_SUBMENU_DASHBOARD</menu>
<menu link="option=com_mokobackup&amp;view=backups" img="class:database">COM_MOKOBACKUP_SUBMENU_BACKUPS</menu>
<menu link="option=com_mokobackup&amp;view=profiles" img="class:cog">COM_MOKOBACKUP_SUBMENU_PROFILES</menu>
</submenu>
@@ -16,5 +16,5 @@ use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController
{
protected $default_view = 'backups';
protected $default_view = 'dashboard';
}
@@ -13,6 +13,7 @@ namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Event\Event;
class BackupEngine
{
@@ -250,6 +251,9 @@ class BackupEngine
// Send success notification
NotificationSender::send($profile, $update, true, implode("\n", $this->log));
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
return [
'success' => true,
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
@@ -275,6 +279,9 @@ class BackupEngine
// Send failure notification
NotificationSender::send($profile, $update, false, implode("\n", $this->log));
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin);
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId];
}
}
@@ -445,6 +452,28 @@ class BackupEngine
));
}
/**
* Dispatch the onMokoBackupAfterRun event so plugins (actionlog, etc.) can react.
*/
private function dispatchAfterRun(bool $success, int $recordId, string $description, int $profileId, string $origin): void
{
try {
$app = Factory::getApplication();
$event = new Event('onMokoBackupAfterRun', [
'success' => $success,
'record_id' => $recordId,
'description' => $description,
'profile_id' => $profileId,
'origin' => $origin,
]);
$app->getDispatcher()->dispatch('onMokoBackupAfterRun', $event);
} catch (\Throwable $e) {
// Never let a listener failure break the backup result
}
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -0,0 +1,163 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class DashboardModel extends BaseDatabaseModel
{
/**
* Get the most recent completed backup record.
*
* @return object|null
*/
public function getLastBackup(): ?object
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('r.*, p.title AS profile_title')
->from($db->quoteName('#__mokobackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokobackup_profiles', 'p') . ' ON p.id = r.profile_id')
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'))
->order($db->quoteName('r.backupend') . ' DESC');
$db->setQuery($query, 0, 1);
return $db->loadObject() ?: null;
}
/**
* Query com_scheduler for the next scheduled MokoBackup task.
*
* @return object|null Object with next_execution and title, or null
*/
public function getNextScheduled(): ?object
{
$db = $this->getDatabase();
try {
$query = $db->getQuery(true)
->select($db->quoteName(['t.next_execution', 't.title']))
->from($db->quoteName('#__scheduler_tasks', 't'))
->where($db->quoteName('t.type') . ' = ' . $db->quote('mokobackup.run_profile'))
->where($db->quoteName('t.state') . ' = 1')
->order($db->quoteName('t.next_execution') . ' ASC');
$db->setQuery($query, 0, 1);
return $db->loadObject() ?: null;
} catch (\Throwable $e) {
return null;
}
}
/**
* Get backup statistics.
*
* @return object Object with total_count, total_size, fail_count_7d
*/
public function getStats(): object
{
$db = $this->getDatabase();
// Total completed backups and storage
$query = $db->getQuery(true)
->select('COUNT(*) AS total_count')
->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$stats = $db->loadObject();
// Failures in last 7 days
$cutoff = date('Y-m-d H:i:s', strtotime('-7 days'));
$query = $db->getQuery(true)
->select('COUNT(*) AS fail_count')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff));
$db->setQuery($query);
$stats->fail_count_7d = (int) $db->loadResult();
return $stats;
}
/**
* Check system health for backup readiness.
*
* @return array Array of check results [{label, status, detail}]
*/
public function getSystemHealth(): array
{
$checks = [];
// PHP version
$checks[] = (object) [
'label' => 'PHP Version',
'status' => version_compare(PHP_VERSION, '8.1.0', '>='),
'detail' => PHP_VERSION,
];
// ZipArchive extension
$checks[] = (object) [
'label' => 'ZipArchive',
'status' => extension_loaded('zip'),
'detail' => extension_loaded('zip') ? 'Loaded' : 'Not loaded',
];
// AES-256 encryption support
$aesSupport = defined('ZipArchive::EM_AES_256');
$checks[] = (object) [
'label' => 'AES-256 Encryption',
'status' => $aesSupport,
'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+',
];
// Backup directory writable
$backupDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups';
$writable = is_dir($backupDir) && is_writable($backupDir);
$checks[] = (object) [
'label' => 'Backup Directory',
'status' => $writable,
'detail' => $writable ? 'Writable' : 'Not writable or missing',
];
// Disk space
$freeSpace = @disk_free_space($backupDir ?: JPATH_ROOT);
$freeGB = $freeSpace ? round($freeSpace / 1073741824, 1) : 0;
$checks[] = (object) [
'label' => 'Free Disk Space',
'status' => $freeGB >= 1.0,
'detail' => $freeGB . ' GB free',
];
return $checks;
}
/**
* Get published backup profiles for the quick-action selector.
*
* @return array
*/
public function getProfiles(): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'title', 'backup_type']))
->from($db->quoteName('#__mokobackup_profiles'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,48 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\Administrator\View\Dashboard;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
public ?object $lastBackup = null;
public ?object $nextScheduled = null;
public object $stats;
public array $systemHealth = [];
public array $profiles = [];
public function display($tpl = null): void
{
/** @var \Joomla\Component\MokoBackup\Administrator\Model\DashboardModel $model */
$model = $this->getModel();
$this->lastBackup = $model->getLastBackup();
$this->nextScheduled = $model->getNextScheduled();
$this->stats = $model->getStats();
$this->systemHealth = $model->getSystemHealth();
$this->profiles = $model->getProfiles();
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOBACKUP_DASHBOARD_TITLE'), 'archive');
ToolbarHelper::preferences('com_mokobackup');
}
}
@@ -0,0 +1,265 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false);
?>
<div class="row">
<!-- Row 1: Status Cards -->
<div class="col-md-3 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<span class="icon-database fs-1 text-primary" aria-hidden="true"></span>
<h5 class="card-title mt-2"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_LAST_BACKUP'); ?></h5>
<?php if ($this->lastBackup) : ?>
<p class="card-text text-success fw-bold">
<?php echo HTMLHelper::_('date', $this->lastBackup->backupend, Text::_('DATE_FORMAT_LC4')); ?>
</p>
<small class="text-muted">
<?php echo $this->escape($this->lastBackup->profile_title); ?>
&mdash;
<?php echo HTMLHelper::_('number.bytes', $this->lastBackup->total_size); ?>
</small>
<?php else : ?>
<p class="card-text text-warning"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_NO_BACKUPS'); ?></p>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<span class="icon-calendar fs-1 text-info" aria-hidden="true"></span>
<h5 class="card-title mt-2"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_NEXT_SCHEDULED'); ?></h5>
<?php if ($this->nextScheduled) : ?>
<p class="card-text fw-bold">
<?php echo HTMLHelper::_('date', $this->nextScheduled->next_execution, Text::_('DATE_FORMAT_LC4')); ?>
</p>
<small class="text-muted"><?php echo $this->escape($this->nextScheduled->title); ?></small>
<?php else : ?>
<p class="card-text text-muted"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_NO_SCHEDULED'); ?></p>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<span class="icon-copy fs-1 text-secondary" aria-hidden="true"></span>
<h5 class="card-title mt-2"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_TOTAL_BACKUPS'); ?></h5>
<p class="card-text fw-bold fs-3"><?php echo (int) $this->stats->total_count; ?></p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<span class="icon-folder-open fs-1 text-warning" aria-hidden="true"></span>
<h5 class="card-title mt-2"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_STORAGE'); ?></h5>
<p class="card-text fw-bold fs-3">
<?php echo HTMLHelper::_('number.bytes', (int) $this->stats->total_size); ?>
</p>
<?php if ($this->stats->fail_count_7d > 0) : ?>
<span class="badge bg-danger">
<?php echo Text::sprintf('COM_MOKOBACKUP_DASHBOARD_FAILURES_7D', $this->stats->fail_count_7d); ?>
</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
<!-- Row 2: Quick Actions -->
<div class="row mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_QUICK_ACTIONS'); ?></h5>
</div>
<div class="card-body">
<?php if (!empty($this->profiles)) : ?>
<div class="d-flex align-items-center gap-3 mb-3">
<select id="mb-profile-select" class="form-select" style="max-width:250px;">
<?php foreach ($this->profiles as $profile) : ?>
<option value="<?php echo (int) $profile->id; ?>">
<?php echo $this->escape($profile->title); ?>
(<?php echo $this->escape($profile->backup_type); ?>)
</option>
<?php endforeach; ?>
</select>
<button type="button" class="btn btn-primary" onclick="window.mokobackupStart()">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW'); ?>
</button>
</div>
<?php endif; ?>
<div class="list-group">
<a href="<?php echo Route::_('index.php?option=com_mokobackup&view=backups'); ?>" class="list-group-item list-group-item-action">
<span class="icon-database" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOBACKUP_SUBMENU_BACKUPS'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_mokobackup&view=profiles'); ?>" class="list-group-item list-group-item-action">
<span class="icon-cog" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOBACKUP_SUBMENU_PROFILES'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_scheduler&view=tasks'); ?>" class="list-group-item list-group-item-action">
<span class="icon-calendar" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_SCHEDULED_TASKS'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_installer&view=updatesites'); ?>" class="list-group-item list-group-item-action">
<span class="icon-refresh" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_UPDATE_SITE'); ?>
</a>
</div>
</div>
</div>
</div>
<!-- Row 2 right: System Health -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_SYSTEM_HEALTH'); ?></h5>
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tbody>
<?php foreach ($this->systemHealth as $check) : ?>
<tr>
<td class="w-1 text-center">
<?php if ($check->status) : ?>
<span class="icon-publish text-success" aria-hidden="true"></span>
<?php else : ?>
<span class="icon-unpublish text-danger" aria-hidden="true"></span>
<?php endif; ?>
</td>
<td><?php echo $this->escape($check->label); ?></td>
<td class="text-muted"><?php echo $this->escape($check->detail); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Stepped Backup Modal (reused from backups view) -->
<div id="mokobackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
</div>
<p id="mb-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
<p id="mb-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
</div>
</div>
<script>
(function() {
const AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
const TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
function showModal() {
document.getElementById('mokobackup-modal').style.display = 'block';
}
function hideModal() {
document.getElementById('mokobackup-modal').style.display = 'none';
}
function updateProgress(progress, message, phase) {
const bar = document.getElementById('mb-progress-bar');
bar.style.width = progress + '%';
bar.textContent = progress + '%';
document.getElementById('mb-status').textContent = message;
document.getElementById('mb-phase').textContent = 'Phase: ' + phase;
}
async function postAjax(params) {
const form = new URLSearchParams();
form.append(TOKEN_NAME, '1');
for (const [k, v] of Object.entries(params)) {
form.append(k, v);
}
const res = await fetch(AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
return res.json();
}
async function startSteppedBackup() {
const profileSelect = document.getElementById('mb-profile-select');
const profileId = profileSelect ? profileSelect.value : '1';
showModal();
updateProgress(0, 'Initializing backup...', 'init');
try {
const initResult = await postAjax({
task: 'ajax.init',
profile_id: profileId
});
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 3000);
return;
}
const sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
let done = false;
while (!done) {
const stepResult = await postAjax({
task: 'ajax.step',
session_id: sessionId
});
if (stepResult.error) {
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
document.getElementById('mb-modal-title').textContent = 'Backup Complete';
setTimeout(function() {
hideModal();
location.reload();
}, 2000);
} catch (err) {
updateProgress(0, 'ERROR: ' + err.message, 'failed');
setTimeout(hideModal, 5000);
}
}
window.mokobackupStart = startSteppedBackup;
})();
</script>
@@ -0,0 +1,9 @@
; MokoJoomBackup — Actionlog Plugin language file (en-GB)
PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED="User {username} created backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED="User {username} updated backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED="User {username} deleted backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED="User {username} deleted backup record &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE="Backup completed: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED="Backup FAILED: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
@@ -0,0 +1,3 @@
; MokoJoomBackup — Actionlog Plugin system language file (en-GB)
PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
@@ -0,0 +1,9 @@
; MokoJoomBackup — Actionlog Plugin language file (en-US)
PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED="User {username} created backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED="User {username} updated backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED="User {username} deleted backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED="User {username} deleted backup record &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE="Backup completed: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED="Backup FAILED: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
@@ -0,0 +1,3 @@
; MokoJoomBackup — Actionlog Plugin system language file (en-US)
PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_actionlog_mokobackup
* @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
*/
defined('_JEXEC') or die;
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage plg_actionlog_mokobackup
* @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
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>plg_actionlog_mokobackup</name>
<version>01.01.04-dev</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\Actionlog\MokoBackup</namespace>
<files>
<filename plugin="mokobackup">mokobackup.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_actionlog_mokobackup.ini</language>
<language tag="en-GB">language/en-GB/plg_actionlog_mokobackup.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_actionlog_mokobackup
* @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
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\Actionlog\MokoBackup\Extension\MokoBackupActionlog;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new MokoBackupActionlog(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('actionlog', 'mokobackup')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,174 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_actionlog_mokobackup
* @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\Plugin\Actionlog\MokoBackup\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Event\Model;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Actionlogs\Administrator\Helper\ActionlogsHelper;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
final class MokoBackupActionlog extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onContentAfterSave' => 'onContentAfterSave',
'onContentAfterDelete' => 'onContentAfterDelete',
'onMokoBackupAfterRun' => 'onMokoBackupAfterRun',
];
}
/**
* Log when a backup profile is saved (created or updated).
*/
public function onContentAfterSave(Event $event): void
{
[$context, $table, $isNew] = array_values($event->getArguments());
if ($context !== 'com_mokobackup.profile') {
return;
}
$messageKey = $isNew
? 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED'
: 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED';
$this->addLog(
[
$messageKey,
'id' => $table->id,
'title' => $table->title,
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
$messageKey,
'com_mokobackup.profile',
$this->getCurrentUserId()
);
}
/**
* Log when a backup profile or record is deleted.
*/
public function onContentAfterDelete(Event $event): void
{
[$context, $table] = array_values($event->getArguments());
if ($context === 'com_mokobackup.profile') {
$this->addLog(
[
'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED',
'id' => $table->id,
'title' => $table->title ?? '',
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED',
'com_mokobackup.profile',
$this->getCurrentUserId()
);
} elseif ($context === 'com_mokobackup.backup') {
$this->addLog(
[
'PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED',
'id' => $table->id,
'title' => $table->description ?? 'Backup #' . $table->id,
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
'PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED',
'com_mokobackup.backup',
$this->getCurrentUserId()
);
}
}
/**
* Log when a backup completes or fails.
* This event should be dispatched from BackupEngine.
*/
public function onMokoBackupAfterRun(Event $event): void
{
$args = $event->getArguments();
$success = $args['success'] ?? false;
$recordId = $args['record_id'] ?? 0;
$description = $args['description'] ?? '';
$profileId = $args['profile_id'] ?? 0;
$origin = $args['origin'] ?? 'backend';
$messageKey = $success
? 'PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE'
: 'PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED';
$this->addLog(
[
$messageKey,
'id' => $recordId,
'title' => $description ?: 'Backup #' . $recordId,
'profile_id' => $profileId,
'origin' => $origin,
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
$messageKey,
'com_mokobackup.backup',
$this->getCurrentUserId()
);
}
/**
* Write an action log entry.
*/
private function addLog(array $message, string $messageLanguageKey, string $context, int $userId): void
{
$params = [
'message_language_key' => $messageLanguageKey,
'message' => json_encode($message),
'date' => date('Y-m-d H:i:s'),
'extension' => 'com_mokobackup',
'user_id' => $userId,
'ip_address' => ActionlogsHelper::getIp(),
'item_id' => $message['id'] ?? 0,
];
try {
$db = Factory::getDbo();
$db->insertObject('#__action_logs', (object) $params);
} catch (\Throwable $e) {
// Non-critical — don't break the operation
}
}
private function getCurrentUserId(): int
{
try {
return (int) Factory::getApplication()->getIdentity()->id;
} catch (\Throwable $e) {
return 0;
}
}
private function getCurrentUserName(): string
{
try {
return Factory::getApplication()->getIdentity()->username ?: 'system';
} catch (\Throwable $e) {
return 'system';
}
}
}
@@ -0,0 +1,3 @@
; MokoJoomBackup — Console Plugin language file (en-GB)
PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
@@ -0,0 +1,3 @@
; MokoJoomBackup — Console Plugin system language file (en-GB)
PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
@@ -0,0 +1,3 @@
; MokoJoomBackup — Console Plugin language file (en-US)
PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
@@ -0,0 +1,3 @@
; MokoJoomBackup — Console Plugin system language file (en-US)
PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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
*/
defined('_JEXEC') or die;
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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
-->
<extension type="plugin" group="console" method="upgrade">
<name>plg_console_mokobackup</name>
<version>01.01.04-dev</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_CONSOLE_MOKOBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\Console\MokoBackup</namespace>
<files>
<filename plugin="mokobackup">mokobackup.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_console_mokobackup.ini</language>
<language tag="en-GB">language/en-GB/plg_console_mokobackup.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\Console\MokoBackup\Extension\MokoBackupConsole;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new MokoBackupConsole(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('console', 'mokobackup')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,125 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class CleanupCommand extends AbstractCommand
{
protected static $defaultName = 'mokobackup:cleanup';
protected function configure(): void
{
$this->setDescription('Clean up old backup records and archive files');
$this->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Max age in days', '30');
$this->addOption('max-count', null, InputOption::VALUE_REQUIRED, 'Max number of backups to keep', '10');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without deleting');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$maxAge = (int) $input->getOption('max-age');
$maxCount = (int) $input->getOption('max-count');
$dryRun = $input->getOption('dry-run');
$io->title('MokoJoomBackup — Cleanup');
if ($dryRun) {
$io->note('Dry run — no files will be deleted.');
}
$db = Factory::getDbo();
$deleted = 0;
// Delete by age
$cutoff = date('Y-m-d H:i:s', strtotime("-{$maxAge} days"));
$query = $db->getQuery(true)
->select('id, absolute_path, description, backupstart')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$expired = $db->loadObjectList();
foreach ($expired as $record) {
$io->text('Expired: #' . $record->id . ' — ' . $record->backupstart . ' — ' . ($record->description ?: 'no description'));
if (!$dryRun) {
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
@unlink($record->absolute_path);
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
}
$deleted++;
}
// Enforce max count
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokobackup_records'))
->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, description, backupstart')
->from($db->quoteName('#__mokobackup_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) {
$io->text('Over limit: #' . $record->id . ' — ' . $record->backupstart);
if (!$dryRun) {
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
@unlink($record->absolute_path);
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
}
$deleted++;
}
}
if ($deleted === 0) {
$io->success('No backups to clean up.');
} else {
$io->success(($dryRun ? 'Would delete ' : 'Deleted ') . $deleted . ' backup record(s).');
}
return self::SUCCESS;
}
}
@@ -0,0 +1,87 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class ListCommand extends AbstractCommand
{
protected static $defaultName = 'mokobackup:list';
protected function configure(): void
{
$this->setDescription('List backup records');
$this->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Number of records to show', '20');
$this->addOption('status', 's', InputOption::VALUE_OPTIONAL, 'Filter by status (complete, fail, running)');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$limit = (int) $input->getOption('limit');
$status = $input->getOption('status');
$io->title('MokoJoomBackup — Backup Records');
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('r.id, r.description, r.status, r.origin, r.backup_type, r.total_size, r.backupstart, r.backupend')
->select($db->quoteName('p.title', 'profile_title'))
->from($db->quoteName('#__mokobackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokobackup_profiles', 'p') . ' ON p.id = r.profile_id')
->order($db->quoteName('r.backupstart') . ' DESC');
if ($status) {
$query->where($db->quoteName('r.status') . ' = ' . $db->quote($status));
}
$db->setQuery($query, 0, $limit);
$records = $db->loadObjectList();
if (empty($records)) {
$io->info('No backup records found.');
return self::SUCCESS;
}
$rows = [];
foreach ($records as $record) {
$size = $record->total_size > 0
? round($record->total_size / 1048576, 2) . ' MB'
: '—';
$rows[] = [
$record->id,
$record->profile_title ?: '—',
$record->status,
$record->backup_type,
$size,
$record->origin,
$record->backupstart,
];
}
$io->table(
['ID', 'Profile', 'Status', 'Type', 'Size', 'Origin', 'Started'],
$rows
);
return self::SUCCESS;
}
}
@@ -0,0 +1,68 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class ProfilesCommand extends AbstractCommand
{
protected static $defaultName = 'mokobackup:profiles';
protected function configure(): void
{
$this->setDescription('List available backup profiles');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('MokoJoomBackup — Backup Profiles');
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('id, title, backup_type, published, ordering')
->from($db->quoteName('#__mokobackup_profiles'))
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$profiles = $db->loadObjectList();
if (empty($profiles)) {
$io->info('No backup profiles found.');
return self::SUCCESS;
}
$rows = [];
foreach ($profiles as $profile) {
$rows[] = [
$profile->id,
$profile->title,
$profile->backup_type,
$profile->published ? 'Yes' : 'No',
];
}
$io->table(
['ID', 'Title', 'Type', 'Published'],
$rows
);
return self::SUCCESS;
}
}
@@ -0,0 +1,101 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoBackup\Administrator\Engine\RestoreEngine;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class RestoreCommand extends AbstractCommand
{
protected static $defaultName = 'mokobackup:restore';
protected function configure(): void
{
$this->setDescription('Restore a backup by record ID');
$this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$recordId = (int) $input->getArgument('id');
$io->title('MokoJoomBackup — Restore Backup');
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('id') . ' = ' . $recordId);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
$io->error('Backup record not found: ' . $recordId);
return self::FAILURE;
}
if ($record->status !== 'complete') {
$io->error('Cannot restore — backup status is: ' . $record->status);
return self::FAILURE;
}
if (empty($record->absolute_path) || !is_file($record->absolute_path)) {
$io->error('Backup archive not found: ' . ($record->absolute_path ?: 'no path'));
return self::FAILURE;
}
$io->warning('This will overwrite the current site files and/or database.');
$io->text('Archive: ' . $record->absolute_path);
$io->text('Type: ' . $record->backup_type);
if (!$io->confirm('Are you sure you want to continue?', false)) {
$io->info('Restore cancelled.');
return self::SUCCESS;
}
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/RestoreEngine.php';
if (!file_exists($engineFile)) {
$io->error('RestoreEngine not found. Is the component fully installed?');
return self::FAILURE;
}
if (!class_exists(RestoreEngine::class)) {
require_once $engineFile;
}
$engine = new RestoreEngine();
$result = $engine->restore($record->absolute_path, $record->backup_type);
if ($result['success']) {
$io->success($result['message']);
return self::SUCCESS;
}
$io->error($result['message']);
return self::FAILURE;
}
}
@@ -0,0 +1,68 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class RunCommand extends AbstractCommand
{
protected static $defaultName = 'mokobackup:run';
protected function configure(): void
{
$this->setDescription('Run a backup using a specified profile');
$this->addOption('profile', 'p', InputOption::VALUE_REQUIRED, 'Profile ID to use', '1');
$this->addOption('description', 'd', InputOption::VALUE_OPTIONAL, 'Backup description', '');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$profileId = (int) $input->getOption('profile');
$desc = $input->getOption('description') ?: '';
$io->title('MokoJoomBackup — Run Backup');
$io->text('Profile ID: ' . $profileId);
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/BackupEngine.php';
if (!file_exists($engineFile)) {
$io->error('MokoJoomBackup component not installed.');
return self::FAILURE;
}
if (!class_exists(BackupEngine::class)) {
require_once $engineFile;
}
$engine = new BackupEngine();
$result = $engine->run($profileId, $desc ?: 'CLI backup', 'cli');
if ($result['success']) {
$io->success($result['message']);
return self::SUCCESS;
}
$io->error($result['message']);
return self::FAILURE;
}
}
@@ -0,0 +1,45 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Joomla\Plugin\Console\MokoBackup\Command\CleanupCommand;
use Joomla\Plugin\Console\MokoBackup\Command\ListCommand;
use Joomla\Plugin\Console\MokoBackup\Command\ProfilesCommand;
use Joomla\Plugin\Console\MokoBackup\Command\RestoreCommand;
use Joomla\Plugin\Console\MokoBackup\Command\RunCommand;
final class MokoBackupConsole extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
\Joomla\Application\Event\ApplicationEvents::BEFORE_EXECUTE => 'registerCommands',
];
}
public function registerCommands(Event $event): void
{
$app = $this->getApplication();
$app->addCommand(new RunCommand());
$app->addCommand(new ListCommand());
$app->addCommand(new ProfilesCommand());
$app->addCommand(new RestoreCommand());
$app->addCommand(new CleanupCommand());
}
}
@@ -0,0 +1,9 @@
; MokoJoomBackup — Content Plugin language file (en-GB)
PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL="Backup Before Install"
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL_DESC="Run an automatic backup before a new extension is installed."
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE="Backup Before Update"
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE_DESC="Run an automatic backup before an extension is updated."
PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE="Backup Profile"
PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE_DESC="Which backup profile to use for automatic backups."
@@ -0,0 +1,3 @@
; MokoJoomBackup — Content Plugin system language file (en-GB)
PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
@@ -0,0 +1,9 @@
; MokoJoomBackup — Content Plugin language file (en-US)
PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL="Backup Before Install"
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL_DESC="Run an automatic backup before a new extension is installed."
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE="Backup Before Update"
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE_DESC="Run an automatic backup before an extension is updated."
PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE="Backup Profile"
PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE_DESC="Which backup profile to use for automatic backups."
@@ -0,0 +1,3 @@
; MokoJoomBackup — Content Plugin system language file (en-US)
PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_content_mokobackup
* @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
*/
defined('_JEXEC') or die;
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage plg_content_mokobackup
* @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
-->
<extension type="plugin" group="content" method="upgrade">
<name>plg_content_mokobackup</name>
<version>01.01.04-dev</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>PLG_CONTENT_MOKOBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\Content\MokoBackup</namespace>
<files>
<filename plugin="mokobackup">mokobackup.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_content_mokobackup.ini</language>
<language tag="en-GB">language/en-GB/plg_content_mokobackup.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="backup_before_install"
type="radio"
label="PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL"
description="PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="backup_before_update"
type="radio"
label="PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE"
description="PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="profile_id"
type="sql"
label="PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE"
description="PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE_DESC"
query="SELECT id AS value, title AS text FROM #__mokobackup_profiles WHERE published = 1 ORDER BY ordering ASC"
default="1"
>
<option value="1">Default Backup Profile</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_content_mokobackup
* @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
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\Content\MokoBackup\Extension\MokoBackupContent;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new MokoBackupContent(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('content', 'mokobackup')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,95 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_content_mokobackup
* @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\Plugin\Content\MokoBackup\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
final class MokoBackupContent extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onExtensionBeforeInstall' => 'onExtensionBeforeInstall',
'onExtensionBeforeUpdate' => 'onExtensionBeforeUpdate',
];
}
/**
* Trigger a backup before a new extension is installed.
*/
public function onExtensionBeforeInstall(Event $event): void
{
if (!(int) $this->params->get('backup_before_install', 0)) {
return;
}
$this->triggerAutoBackup('Pre-install backup');
}
/**
* Trigger a backup before an extension is updated.
*/
public function onExtensionBeforeUpdate(Event $event): void
{
if (!(int) $this->params->get('backup_before_update', 1)) {
return;
}
$this->triggerAutoBackup('Pre-update backup');
}
/**
* Run a backup using the configured profile.
*/
private function triggerAutoBackup(string $description): void
{
$profileId = (int) $this->params->get('profile_id', 1);
// Throttle: only one auto-backup per hour via session
$session = Factory::getSession();
$lastRun = $session->get('mokobackup.content_last_autobackup', 0);
if (time() - $lastRun < 3600) {
return;
}
$session->set('mokobackup.content_last_autobackup', time());
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/BackupEngine.php';
if (!file_exists($engineFile)) {
return;
}
if (!class_exists(BackupEngine::class)) {
require_once $engineFile;
}
try {
$engine = new BackupEngine();
$engine->run($profileId, $description, 'backend');
} catch (\Throwable $e) {
// Non-fatal — log and continue with the install/update
Factory::getApplication()->enqueueMessage(
'MokoJoomBackup auto-backup failed: ' . $e->getMessage(),
'warning'
);
}
}
}
+3
View File
@@ -25,6 +25,9 @@
<file type="plugin" id="mokobackup" group="task">plg_task_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="quickicon">plg_quickicon_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="webservices">plg_webservices_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="console">plg_console_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="content">plg_content_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="actionlog">plg_actionlog_mokobackup.zip</file>
</files>
<languages>
+33
View File
@@ -108,6 +108,39 @@ class Pkg_MokoBackupInstallerScript
$db->setQuery($query);
$db->execute();
// Enable the console plugin automatically
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('console'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup'));
$db->setQuery($query);
$db->execute();
// Enable the content plugin automatically
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('content'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup'));
$db->setQuery($query);
$db->execute();
// Enable the actionlog plugin automatically
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('actionlog'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup'));
$db->setQuery($query);
$db->execute();
// Create default backup directory
$backupDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups';