chore: rename src/ to source/ per MokoStandards convention
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (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
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (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
Update all references in Makefile, manifest.xml, .gitignore, and CI workflows (ci-joomla, pr-check, repo-health) to use source/ as the primary directory with src/ as a fallback for compatibility. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,10 +0,0 @@
|
||||
; MokoJoomBackup — Package language file (en-GB)
|
||||
; @package MokoJoomBackup
|
||||
; @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
PKG_MOKOBACKUP="Package - MokoJoomBackup"
|
||||
PKG_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API."
|
||||
PKG_MOKOBACKUP_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later."
|
||||
PKG_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,10 +0,0 @@
|
||||
; MokoJoomBackup — Package language file (en-US)
|
||||
; @package MokoJoomBackup
|
||||
; @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
PKG_MOKOBACKUP="Package - MokoJoomBackup"
|
||||
PKG_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API."
|
||||
PKG_MOKOBACKUP_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later."
|
||||
PKG_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,100 +0,0 @@
|
||||
<?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\Api\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\ApiController;
|
||||
use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine;
|
||||
|
||||
class BackupsController extends ApiController
|
||||
{
|
||||
protected $contentType = 'backups';
|
||||
protected $default_view = 'backups';
|
||||
|
||||
/**
|
||||
* Start a new backup (POST /api/index.php/v1/mokobackup/backup)
|
||||
*/
|
||||
public function backup(): static
|
||||
{
|
||||
$data = json_decode($this->input->json->getRaw(), true) ?: [];
|
||||
|
||||
$profileId = (int) ($data['profile'] ?? 1);
|
||||
$description = $data['description'] ?? 'API backup ' . date('Y-m-d H:i:s');
|
||||
|
||||
$engine = new BackupEngine();
|
||||
$result = $engine->run($profileId, $description, 'api');
|
||||
|
||||
if ($result['success']) {
|
||||
$this->app->setHeader('status', 200);
|
||||
echo json_encode(['data' => $result]);
|
||||
} else {
|
||||
$this->app->setHeader('status', 500);
|
||||
echo json_encode(['errors' => [['title' => $result['message']]]]);
|
||||
}
|
||||
|
||||
$this->app->close();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a backup archive (GET /api/index.php/v1/mokobackup/backup/:id/download)
|
||||
*/
|
||||
public function download(): static
|
||||
{
|
||||
$id = $this->input->getInt('id', 0);
|
||||
|
||||
$model = $this->getModel('Backup', 'Administrator');
|
||||
$item = $model->getItem($id);
|
||||
|
||||
if (!$item || !$item->id || !$item->filesexist || !is_file($item->absolute_path)) {
|
||||
$this->app->setHeader('status', 404);
|
||||
echo json_encode(['errors' => [['title' => 'Backup file not found']]]);
|
||||
$this->app->close();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$content = base64_encode(file_get_contents($item->absolute_path));
|
||||
|
||||
$this->app->setHeader('status', 200);
|
||||
echo json_encode(['data' => $content]);
|
||||
$this->app->close();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* List backup profiles (GET /api/index.php/v1/mokobackup/profiles)
|
||||
*/
|
||||
public function profiles(): static
|
||||
{
|
||||
$model = $this->getModel('Profiles', 'Administrator');
|
||||
$items = $model->getItems();
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$data[] = [
|
||||
'type' => 'profiles',
|
||||
'id' => $item->id,
|
||||
'attributes' => $item,
|
||||
];
|
||||
}
|
||||
|
||||
$this->app->setHeader('status', 200);
|
||||
echo json_encode(['data' => $data]);
|
||||
$this->app->close();
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,53 +0,0 @@
|
||||
<?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\Api\View\Backups;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
|
||||
|
||||
class JsonapiView extends BaseApiView
|
||||
{
|
||||
protected $fieldsToRenderItem = [
|
||||
'id',
|
||||
'profile_id',
|
||||
'description',
|
||||
'status',
|
||||
'origin',
|
||||
'backup_type',
|
||||
'archivename',
|
||||
'absolute_path',
|
||||
'total_size',
|
||||
'db_size',
|
||||
'files_count',
|
||||
'tables_count',
|
||||
'multipart',
|
||||
'tag',
|
||||
'backupstart',
|
||||
'backupend',
|
||||
'filesexist',
|
||||
'remote_filename',
|
||||
];
|
||||
|
||||
protected $fieldsToRenderList = [
|
||||
'id',
|
||||
'profile_id',
|
||||
'description',
|
||||
'status',
|
||||
'origin',
|
||||
'backup_type',
|
||||
'archivename',
|
||||
'total_size',
|
||||
'backupstart',
|
||||
'backupend',
|
||||
'filesexist',
|
||||
];
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,68 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* CLI backup script for cron/scheduled use.
|
||||
*
|
||||
* Usage:
|
||||
* php cli/mokobackup.php --profile=1 --description="Scheduled backup"
|
||||
*
|
||||
* Must be run from the Joomla root directory.
|
||||
*/
|
||||
|
||||
// Define Joomla constants
|
||||
const _JEXEC = 1;
|
||||
|
||||
// Bootstrap Joomla
|
||||
if (file_exists(dirname(__DIR__, 4) . '/includes/defines.php')) {
|
||||
require_once dirname(__DIR__, 4) . '/includes/defines.php';
|
||||
}
|
||||
|
||||
if (!defined('JPATH_BASE')) {
|
||||
define('JPATH_BASE', dirname(__DIR__, 4));
|
||||
}
|
||||
|
||||
require_once JPATH_BASE . '/includes/framework.php';
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine;
|
||||
|
||||
// Parse CLI arguments
|
||||
$profileId = 1;
|
||||
$description = '';
|
||||
|
||||
foreach ($argv as $arg) {
|
||||
if (str_starts_with($arg, '--profile=')) {
|
||||
$profileId = (int) substr($arg, 10);
|
||||
} elseif (str_starts_with($arg, '--description=')) {
|
||||
$description = substr($arg, 14);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($description)) {
|
||||
$description = 'CLI backup ' . date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
// Boot the application
|
||||
$app = Factory::getApplication('administrator');
|
||||
|
||||
echo "MokoJoomBackup CLI\n";
|
||||
echo "Profile: {$profileId}\n";
|
||||
echo "Description: {$description}\n";
|
||||
echo "Starting backup...\n\n";
|
||||
|
||||
$engine = new BackupEngine();
|
||||
$result = $engine->run($profileId, $description, 'cli');
|
||||
|
||||
if ($result['success']) {
|
||||
echo "SUCCESS: " . $result['message'] . "\n";
|
||||
exit(0);
|
||||
} else {
|
||||
echo "FAILED: " . $result['message'] . "\n";
|
||||
exit(1);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @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
|
||||
-->
|
||||
<config>
|
||||
<fieldset name="general" label="COM_MOKOBACKUP_CONFIG_GENERAL">
|
||||
<field
|
||||
name="default_backup_dir"
|
||||
type="FolderPicker"
|
||||
label="COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR"
|
||||
description="COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC"
|
||||
default="administrator/components/com_mokobackup/backups"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="default_profile"
|
||||
type="sql"
|
||||
label="COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE"
|
||||
description="COM_MOKOBACKUP_CONFIG_DEFAULT_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>
|
||||
<field
|
||||
name="show_update_notice"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE"
|
||||
description="COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="cleanup" label="COM_MOKOBACKUP_CONFIG_CLEANUP">
|
||||
<field
|
||||
name="max_age_days"
|
||||
type="number"
|
||||
label="COM_MOKOBACKUP_CONFIG_MAX_AGE"
|
||||
description="COM_MOKOBACKUP_CONFIG_MAX_AGE_DESC"
|
||||
default="30"
|
||||
min="1"
|
||||
max="365"
|
||||
/>
|
||||
<field
|
||||
name="max_backups"
|
||||
type="number"
|
||||
label="COM_MOKOBACKUP_CONFIG_MAX_BACKUPS"
|
||||
description="COM_MOKOBACKUP_CONFIG_MAX_BACKUPS_DESC"
|
||||
default="10"
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="notifications" label="COM_MOKOBACKUP_CONFIG_NOTIFICATIONS">
|
||||
<field
|
||||
name="notify_email"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL"
|
||||
description="COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL_DESC"
|
||||
default=""
|
||||
filter="string"
|
||||
/>
|
||||
<field
|
||||
name="notify_on_success"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS"
|
||||
description="COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="notify_on_failure"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE"
|
||||
description="COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
|
||||
description="JCONFIG_PERMISSIONS_DESC">
|
||||
<field
|
||||
name="rules"
|
||||
type="rules"
|
||||
label="JCONFIG_PERMISSIONS_LABEL"
|
||||
filter="rules"
|
||||
validate="rules"
|
||||
component="com_mokobackup"
|
||||
section="component"
|
||||
/>
|
||||
</fieldset>
|
||||
</config>
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<form>
|
||||
<fieldset name="general">
|
||||
<field name="id" type="hidden" />
|
||||
<field name="profile_id" type="hidden" />
|
||||
<field name="description" type="text" label="COM_MOKOBACKUP_FIELD_DESCRIPTION" readonly="true" />
|
||||
<field name="status" type="text" label="COM_MOKOBACKUP_FIELD_STATUS" readonly="true" />
|
||||
<field name="origin" type="text" label="COM_MOKOBACKUP_FIELD_ORIGIN" readonly="true" />
|
||||
<field name="backup_type" type="text" label="COM_MOKOBACKUP_FIELD_BACKUP_TYPE" readonly="true" />
|
||||
<field name="archivename" type="text" label="COM_MOKOBACKUP_FIELD_ARCHIVE" readonly="true" />
|
||||
<field name="total_size" type="text" label="COM_MOKOBACKUP_FIELD_SIZE" readonly="true" />
|
||||
<field name="backupstart" type="text" label="COM_MOKOBACKUP_FIELD_START" readonly="true" />
|
||||
<field name="backupend" type="text" label="COM_MOKOBACKUP_FIELD_END" readonly="true" />
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -1,47 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<form>
|
||||
<fields name="filter">
|
||||
<field
|
||||
name="search"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FILTER_SEARCH"
|
||||
hint="JSEARCH_FILTER"
|
||||
/>
|
||||
<field
|
||||
name="status"
|
||||
type="list"
|
||||
label="COM_MOKOBACKUP_FILTER_STATUS"
|
||||
onchange="this.form.submit();"
|
||||
>
|
||||
<option value="">COM_MOKOBACKUP_FILTER_STATUS_ALL</option>
|
||||
<option value="complete">COM_MOKOBACKUP_STATUS_COMPLETE</option>
|
||||
<option value="running">COM_MOKOBACKUP_STATUS_RUNNING</option>
|
||||
<option value="fail">COM_MOKOBACKUP_STATUS_FAIL</option>
|
||||
<option value="pending">COM_MOKOBACKUP_STATUS_PENDING</option>
|
||||
</field>
|
||||
</fields>
|
||||
|
||||
<fields name="list">
|
||||
<field
|
||||
name="fullordering"
|
||||
type="list"
|
||||
label="JGLOBAL_SORT_BY"
|
||||
default="a.backupstart DESC"
|
||||
onchange="this.form.submit();"
|
||||
>
|
||||
<option value="a.backupstart DESC">COM_MOKOBACKUP_HEADING_DATE_DESC</option>
|
||||
<option value="a.backupstart ASC">COM_MOKOBACKUP_HEADING_DATE_ASC</option>
|
||||
<option value="a.total_size DESC">COM_MOKOBACKUP_HEADING_SIZE_DESC</option>
|
||||
<option value="a.total_size ASC">COM_MOKOBACKUP_HEADING_SIZE_ASC</option>
|
||||
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
|
||||
<option value="a.id ASC">JGRID_HEADING_ID_ASC</option>
|
||||
</field>
|
||||
<field
|
||||
name="limit"
|
||||
type="limitbox"
|
||||
label="JGLOBAL_LIST_LIMIT"
|
||||
default="25"
|
||||
onchange="this.form.submit();"
|
||||
/>
|
||||
</fields>
|
||||
</form>
|
||||
@@ -1,44 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<form>
|
||||
<fields name="filter">
|
||||
<field
|
||||
name="search"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FILTER_SEARCH"
|
||||
hint="JSEARCH_FILTER"
|
||||
/>
|
||||
<field
|
||||
name="published"
|
||||
type="list"
|
||||
label="JSTATUS"
|
||||
onchange="this.form.submit();"
|
||||
>
|
||||
<option value="">JOPTION_SELECT_PUBLISHED</option>
|
||||
<option value="1">JPUBLISHED</option>
|
||||
<option value="0">JUNPUBLISHED</option>
|
||||
</field>
|
||||
</fields>
|
||||
|
||||
<fields name="list">
|
||||
<field
|
||||
name="fullordering"
|
||||
type="list"
|
||||
label="JGLOBAL_SORT_BY"
|
||||
default="a.ordering ASC"
|
||||
onchange="this.form.submit();"
|
||||
>
|
||||
<option value="a.ordering ASC">JFIELD_ORDERING_LABEL_ASC</option>
|
||||
<option value="a.title ASC">COM_MOKOBACKUP_HEADING_TITLE_ASC</option>
|
||||
<option value="a.title DESC">COM_MOKOBACKUP_HEADING_TITLE_DESC</option>
|
||||
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
|
||||
<option value="a.id ASC">JGRID_HEADING_ID_ASC</option>
|
||||
</field>
|
||||
<field
|
||||
name="limit"
|
||||
type="limitbox"
|
||||
label="JGLOBAL_LIST_LIMIT"
|
||||
default="25"
|
||||
onchange="this.form.submit();"
|
||||
/>
|
||||
</fields>
|
||||
</form>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,373 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<form>
|
||||
<fieldset name="general" label="COM_MOKOBACKUP_FIELDSET_GENERAL">
|
||||
<field
|
||||
name="title"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_TITLE"
|
||||
description="COM_MOKOBACKUP_FIELD_TITLE_DESC"
|
||||
required="true"
|
||||
maxlength="255"
|
||||
/>
|
||||
<field
|
||||
name="description"
|
||||
type="textarea"
|
||||
label="COM_MOKOBACKUP_FIELD_DESCRIPTION"
|
||||
description="COM_MOKOBACKUP_FIELD_DESCRIPTION_DESC"
|
||||
rows="3"
|
||||
/>
|
||||
<field
|
||||
name="backup_type"
|
||||
type="list"
|
||||
label="COM_MOKOBACKUP_FIELD_BACKUP_TYPE"
|
||||
description="COM_MOKOBACKUP_FIELD_BACKUP_TYPE_DESC"
|
||||
default="full"
|
||||
>
|
||||
<option value="full">COM_MOKOBACKUP_TYPE_FULL</option>
|
||||
<option value="database">COM_MOKOBACKUP_TYPE_DATABASE</option>
|
||||
<option value="files">COM_MOKOBACKUP_TYPE_FILES</option>
|
||||
<option value="differential">COM_MOKOBACKUP_TYPE_DIFFERENTIAL</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="archive" label="COM_MOKOBACKUP_FIELDSET_ARCHIVE">
|
||||
<field
|
||||
name="archive_format"
|
||||
type="list"
|
||||
label="COM_MOKOBACKUP_FIELD_ARCHIVE_FORMAT"
|
||||
description="COM_MOKOBACKUP_FIELD_ARCHIVE_FORMAT_DESC"
|
||||
default="zip"
|
||||
>
|
||||
<option value="zip">ZIP</option>
|
||||
<option value="tar.gz">tar.gz</option>
|
||||
</field>
|
||||
<field
|
||||
name="compression_level"
|
||||
type="list"
|
||||
label="COM_MOKOBACKUP_FIELD_COMPRESSION"
|
||||
description="COM_MOKOBACKUP_FIELD_COMPRESSION_DESC"
|
||||
default="5"
|
||||
>
|
||||
<option value="0">COM_MOKOBACKUP_COMPRESSION_NONE</option>
|
||||
<option value="1">COM_MOKOBACKUP_COMPRESSION_FASTEST</option>
|
||||
<option value="5">COM_MOKOBACKUP_COMPRESSION_NORMAL</option>
|
||||
<option value="9">COM_MOKOBACKUP_COMPRESSION_BEST</option>
|
||||
</field>
|
||||
<field
|
||||
name="split_size"
|
||||
type="number"
|
||||
label="COM_MOKOBACKUP_FIELD_SPLIT_SIZE"
|
||||
description="COM_MOKOBACKUP_FIELD_SPLIT_SIZE_DESC"
|
||||
default="0"
|
||||
min="0"
|
||||
hint="0 = no splitting"
|
||||
/>
|
||||
<field
|
||||
name="backup_dir"
|
||||
type="FolderPicker"
|
||||
label="COM_MOKOBACKUP_FIELD_BACKUP_DIR"
|
||||
description="COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC"
|
||||
default="administrator/components/com_mokobackup/backups"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="archive_name_format"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
|
||||
description="COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
|
||||
default="[host]_[datetime]_profile[profile_id]"
|
||||
maxlength="512"
|
||||
hint="[host]_[datetime]_profile[profile_id]"
|
||||
/>
|
||||
<field
|
||||
name="include_mokorestore"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE"
|
||||
description="COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="encryption_password"
|
||||
type="password"
|
||||
label="COM_MOKOBACKUP_FIELD_ENCRYPTION_PASSWORD"
|
||||
description="COM_MOKOBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC"
|
||||
maxlength="255"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sidebar" label="COM_MOKOBACKUP_FIELDSET_STATUS">
|
||||
<field
|
||||
name="id"
|
||||
type="hidden"
|
||||
/>
|
||||
<field
|
||||
name="published"
|
||||
type="list"
|
||||
label="JSTATUS"
|
||||
default="1"
|
||||
>
|
||||
<option value="1">JPUBLISHED</option>
|
||||
<option value="0">JUNPUBLISHED</option>
|
||||
</field>
|
||||
<field
|
||||
name="ordering"
|
||||
type="number"
|
||||
label="JFIELD_ORDERING_LABEL"
|
||||
default="0"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="filters" label="COM_MOKOBACKUP_FIELDSET_FILTERS">
|
||||
<field
|
||||
name="exclude_dirs"
|
||||
type="DirectoryFilter"
|
||||
label="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS"
|
||||
description="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC"
|
||||
filter="raw"
|
||||
hint="tmp"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="exclude_files"
|
||||
type="ExcludeList"
|
||||
label="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES"
|
||||
description="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES_DESC"
|
||||
filter="raw"
|
||||
hint="*.bak"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="exclude_tables"
|
||||
type="DatabaseTables"
|
||||
label="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES"
|
||||
description="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_DESC"
|
||||
filter="raw"
|
||||
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="remote" label="COM_MOKOBACKUP_FIELDSET_REMOTE">
|
||||
<field
|
||||
name="remote_storage"
|
||||
type="list"
|
||||
label="COM_MOKOBACKUP_FIELD_REMOTE_STORAGE"
|
||||
description="COM_MOKOBACKUP_FIELD_REMOTE_STORAGE_DESC"
|
||||
default="none"
|
||||
>
|
||||
<option value="none">COM_MOKOBACKUP_REMOTE_NONE</option>
|
||||
<option value="ftp">COM_MOKOBACKUP_REMOTE_FTP</option>
|
||||
<option value="google_drive">COM_MOKOBACKUP_REMOTE_GDRIVE</option>
|
||||
<option value="s3">COM_MOKOBACKUP_REMOTE_S3</option>
|
||||
</field>
|
||||
<field
|
||||
name="remote_keep_local"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_FIELD_KEEP_LOCAL"
|
||||
description="COM_MOKOBACKUP_FIELD_KEEP_LOCAL_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="notifications" label="COM_MOKOBACKUP_FIELDSET_NOTIFICATIONS">
|
||||
<field
|
||||
name="notify_email"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_NOTIFY_EMAIL"
|
||||
description="COM_MOKOBACKUP_FIELD_NOTIFY_EMAIL_DESC"
|
||||
maxlength="512"
|
||||
hint="admin@example.com, backup@example.com"
|
||||
/>
|
||||
<field
|
||||
name="notify_user_groups"
|
||||
type="usergrouplist"
|
||||
label="COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS"
|
||||
description="COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC"
|
||||
multiple="true"
|
||||
layout="joomla.form.field.list-fancy-select"
|
||||
/>
|
||||
<field
|
||||
name="notify_on_success"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_FIELD_NOTIFY_SUCCESS"
|
||||
description="COM_MOKOBACKUP_FIELD_NOTIFY_SUCCESS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="notify_on_failure"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_FIELD_NOTIFY_FAILURE"
|
||||
description="COM_MOKOBACKUP_FIELD_NOTIFY_FAILURE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="ftp" label="COM_MOKOBACKUP_FIELDSET_FTP">
|
||||
<field
|
||||
name="ftp_host"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_FTP_HOST"
|
||||
description="COM_MOKOBACKUP_FIELD_FTP_HOST_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:ftp"
|
||||
/>
|
||||
<field
|
||||
name="ftp_port"
|
||||
type="number"
|
||||
label="COM_MOKOBACKUP_FIELD_FTP_PORT"
|
||||
description="COM_MOKOBACKUP_FIELD_FTP_PORT_DESC"
|
||||
default="21"
|
||||
min="1"
|
||||
max="65535"
|
||||
showon="remote_storage:ftp"
|
||||
/>
|
||||
<field
|
||||
name="ftp_username"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_FTP_USERNAME"
|
||||
maxlength="255"
|
||||
showon="remote_storage:ftp"
|
||||
/>
|
||||
<field
|
||||
name="ftp_password"
|
||||
type="password"
|
||||
label="COM_MOKOBACKUP_FIELD_FTP_PASSWORD"
|
||||
maxlength="255"
|
||||
showon="remote_storage:ftp"
|
||||
/>
|
||||
<field
|
||||
name="ftp_path"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_FTP_PATH"
|
||||
description="COM_MOKOBACKUP_FIELD_FTP_PATH_DESC"
|
||||
default="/backups"
|
||||
maxlength="512"
|
||||
showon="remote_storage:ftp"
|
||||
/>
|
||||
<field
|
||||
name="ftp_passive"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_FIELD_FTP_PASSIVE"
|
||||
description="COM_MOKOBACKUP_FIELD_FTP_PASSIVE_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
showon="remote_storage:ftp"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="ftp_ssl"
|
||||
type="radio"
|
||||
label="COM_MOKOBACKUP_FIELD_FTP_SSL"
|
||||
description="COM_MOKOBACKUP_FIELD_FTP_SSL_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
showon="remote_storage:ftp"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="google_drive" label="COM_MOKOBACKUP_FIELDSET_GDRIVE">
|
||||
<field
|
||||
name="gdrive_client_id"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_GDRIVE_CLIENT_ID"
|
||||
description="COM_MOKOBACKUP_FIELD_GDRIVE_CLIENT_ID_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:google_drive"
|
||||
/>
|
||||
<field
|
||||
name="gdrive_client_secret"
|
||||
type="password"
|
||||
label="COM_MOKOBACKUP_FIELD_GDRIVE_CLIENT_SECRET"
|
||||
maxlength="255"
|
||||
showon="remote_storage:google_drive"
|
||||
/>
|
||||
<field
|
||||
name="gdrive_refresh_token"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_GDRIVE_REFRESH_TOKEN"
|
||||
description="COM_MOKOBACKUP_FIELD_GDRIVE_REFRESH_TOKEN_DESC"
|
||||
maxlength="512"
|
||||
showon="remote_storage:google_drive"
|
||||
/>
|
||||
<field
|
||||
name="gdrive_folder_id"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_GDRIVE_FOLDER_ID"
|
||||
description="COM_MOKOBACKUP_FIELD_GDRIVE_FOLDER_ID_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:google_drive"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="s3" label="COM_MOKOBACKUP_FIELDSET_S3">
|
||||
<field
|
||||
name="s3_endpoint"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_S3_ENDPOINT"
|
||||
description="COM_MOKOBACKUP_FIELD_S3_ENDPOINT_DESC"
|
||||
maxlength="512"
|
||||
hint="https://s3.amazonaws.com"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
<field
|
||||
name="s3_region"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_S3_REGION"
|
||||
description="COM_MOKOBACKUP_FIELD_S3_REGION_DESC"
|
||||
default="us-east-1"
|
||||
maxlength="50"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
<field
|
||||
name="s3_access_key"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_S3_ACCESS_KEY"
|
||||
maxlength="255"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
<field
|
||||
name="s3_secret_key"
|
||||
type="password"
|
||||
label="COM_MOKOBACKUP_FIELD_S3_SECRET_KEY"
|
||||
maxlength="255"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
<field
|
||||
name="s3_bucket"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_S3_BUCKET"
|
||||
description="COM_MOKOBACKUP_FIELD_S3_BUCKET_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
<field
|
||||
name="s3_path"
|
||||
type="text"
|
||||
label="COM_MOKOBACKUP_FIELD_S3_PATH"
|
||||
description="COM_MOKOBACKUP_FIELD_S3_PATH_DESC"
|
||||
default="/backups"
|
||||
maxlength="512"
|
||||
showon="remote_storage:s3"
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,264 +0,0 @@
|
||||
; MokoJoomBackup — Component language file (en-GB)
|
||||
; @package MokoJoomBackup
|
||||
; @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
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"
|
||||
COM_MOKOBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
||||
COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
||||
COM_MOKOBACKUP_DOWNLOAD="Download"
|
||||
|
||||
; Backup detail view
|
||||
COM_MOKOBACKUP_BACKUP_DETAIL="Backup Detail"
|
||||
COM_MOKOBACKUP_VIEW_LOG="Backup Log"
|
||||
COM_MOKOBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
||||
COM_MOKOBACKUP_FIELD_PATH="File Path"
|
||||
COM_MOKOBACKUP_FIELD_DB_SIZE="DB Size"
|
||||
COM_MOKOBACKUP_FIELD_REMOTE="Remote Path"
|
||||
|
||||
; Profiles view
|
||||
COM_MOKOBACKUP_PROFILES_TITLE="Backup Profiles"
|
||||
COM_MOKOBACKUP_PROFILES_TABLE_CAPTION="Table of backup profiles"
|
||||
COM_MOKOBACKUP_NO_PROFILES="No backup profiles found."
|
||||
COM_MOKOBACKUP_PROFILE_NEW="New Profile"
|
||||
COM_MOKOBACKUP_PROFILE_EDIT="Edit Profile"
|
||||
|
||||
; Table headings
|
||||
COM_MOKOBACKUP_HEADING_DESCRIPTION="Description"
|
||||
COM_MOKOBACKUP_HEADING_PROFILE="Profile"
|
||||
COM_MOKOBACKUP_HEADING_STATUS="Status"
|
||||
COM_MOKOBACKUP_HEADING_TYPE="Type"
|
||||
COM_MOKOBACKUP_HEADING_SIZE="Size"
|
||||
COM_MOKOBACKUP_HEADING_DATE="Date"
|
||||
COM_MOKOBACKUP_HEADING_ACTIONS="Actions"
|
||||
COM_MOKOBACKUP_HEADING_TITLE="Title"
|
||||
COM_MOKOBACKUP_HEADING_DATE_DESC="Date descending"
|
||||
COM_MOKOBACKUP_HEADING_DATE_ASC="Date ascending"
|
||||
COM_MOKOBACKUP_HEADING_SIZE_DESC="Size descending"
|
||||
COM_MOKOBACKUP_HEADING_SIZE_ASC="Size ascending"
|
||||
COM_MOKOBACKUP_HEADING_TITLE_ASC="Title ascending"
|
||||
COM_MOKOBACKUP_HEADING_TITLE_DESC="Title descending"
|
||||
|
||||
; General fields
|
||||
COM_MOKOBACKUP_FIELD_TITLE="Title"
|
||||
COM_MOKOBACKUP_FIELD_TITLE_DESC="Profile name"
|
||||
COM_MOKOBACKUP_FIELD_DESCRIPTION="Description"
|
||||
COM_MOKOBACKUP_FIELD_DESCRIPTION_DESC="Brief description of this profile"
|
||||
COM_MOKOBACKUP_FIELD_BACKUP_TYPE="Backup Type"
|
||||
COM_MOKOBACKUP_FIELD_BACKUP_TYPE_DESC="What to include in the backup"
|
||||
COM_MOKOBACKUP_FIELD_STATUS="Status"
|
||||
COM_MOKOBACKUP_FIELD_ORIGIN="Origin"
|
||||
COM_MOKOBACKUP_FIELD_SIZE="Total Size"
|
||||
COM_MOKOBACKUP_FIELD_START="Start Time"
|
||||
COM_MOKOBACKUP_FIELD_END="End Time"
|
||||
COM_MOKOBACKUP_FIELD_ARCHIVE="Archive Name"
|
||||
COM_MOKOBACKUP_FIELD_FILES_COUNT="Files Count"
|
||||
COM_MOKOBACKUP_FIELD_TABLES_COUNT="Tables Count"
|
||||
|
||||
; Archive settings
|
||||
COM_MOKOBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format"
|
||||
COM_MOKOBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file"
|
||||
COM_MOKOBACKUP_FIELD_COMPRESSION="Compression Level"
|
||||
COM_MOKOBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower"
|
||||
COM_MOKOBACKUP_COMPRESSION_NONE="None (fastest)"
|
||||
COM_MOKOBACKUP_COMPRESSION_FASTEST="Low (fast)"
|
||||
COM_MOKOBACKUP_COMPRESSION_NORMAL="Normal (balanced)"
|
||||
COM_MOKOBACKUP_COMPRESSION_BEST="Maximum (smallest)"
|
||||
COM_MOKOBACKUP_FIELD_ENCRYPTION_PASSWORD="Encryption Password"
|
||||
COM_MOKOBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the backup archive with AES-256. Leave blank for no encryption. Required to restore encrypted backups."
|
||||
COM_MOKOBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
|
||||
COM_MOKOBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
|
||||
COM_MOKOBACKUP_FIELD_BACKUP_DIR="Backup Directory"
|
||||
COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
|
||||
COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
|
||||
COM_MOKOBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [host] hostname, [date] Ymd, [time] His, [datetime] Ymd_His, [year] [month] [day] [hour] [minute] [second], [profile_id], [profile_name], [site_name], [type], [random]."
|
||||
COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script"
|
||||
COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed."
|
||||
|
||||
; Exclusion filter fields
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC="Browse and check directories to exclude from file backup. You can also type paths manually."
|
||||
COM_MOKOBACKUP_FILTER_EXCLUDED="Excluded"
|
||||
COM_MOKOBACKUP_FILTER_INCLUDED="Included"
|
||||
COM_MOKOBACKUP_FILTER_ADD_MANUAL="Add Path"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_FILES="Exclude Files"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_FILES_DESC="One filename or pattern per line. Supports wildcards (e.g. *.bak, *.tmp)."
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES="Exclude Tables"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_DESC="One table name per line (use #__ prefix). These tables will be skipped during database dump."
|
||||
|
||||
; Remote storage fields
|
||||
COM_MOKOBACKUP_FIELD_REMOTE_STORAGE="Remote Storage"
|
||||
COM_MOKOBACKUP_FIELD_REMOTE_STORAGE_DESC="Optionally upload backup archives to a remote location after creation"
|
||||
COM_MOKOBACKUP_REMOTE_NONE="None (local only)"
|
||||
COM_MOKOBACKUP_REMOTE_FTP="FTP / FTPS"
|
||||
COM_MOKOBACKUP_REMOTE_GDRIVE="Google Drive"
|
||||
COM_MOKOBACKUP_FIELD_KEEP_LOCAL="Keep Local Copy"
|
||||
COM_MOKOBACKUP_FIELD_KEEP_LOCAL_DESC="Keep the local backup file after uploading to remote storage"
|
||||
|
||||
; FTP fields
|
||||
COM_MOKOBACKUP_FIELD_FTP_HOST="FTP Host"
|
||||
COM_MOKOBACKUP_FIELD_FTP_HOST_DESC="FTP server hostname or IP address"
|
||||
COM_MOKOBACKUP_FIELD_FTP_PORT="FTP Port"
|
||||
COM_MOKOBACKUP_FIELD_FTP_PORT_DESC="FTP server port (default: 21)"
|
||||
COM_MOKOBACKUP_FIELD_FTP_USERNAME="FTP Username"
|
||||
COM_MOKOBACKUP_FIELD_FTP_PASSWORD="FTP Password"
|
||||
COM_MOKOBACKUP_FIELD_FTP_PATH="Remote Path"
|
||||
COM_MOKOBACKUP_FIELD_FTP_PATH_DESC="Directory on the FTP server to upload backups to"
|
||||
COM_MOKOBACKUP_FIELD_FTP_PASSIVE="Passive Mode"
|
||||
COM_MOKOBACKUP_FIELD_FTP_PASSIVE_DESC="Use passive mode for FTP connections (recommended)"
|
||||
COM_MOKOBACKUP_FIELD_FTP_SSL="Use FTPS (SSL)"
|
||||
COM_MOKOBACKUP_FIELD_FTP_SSL_DESC="Connect using FTPS (FTP over SSL/TLS)"
|
||||
|
||||
; Google Drive fields
|
||||
COM_MOKOBACKUP_FIELD_GDRIVE_CLIENT_ID="Google Client ID"
|
||||
COM_MOKOBACKUP_FIELD_GDRIVE_CLIENT_ID_DESC="OAuth 2.0 Client ID from Google Cloud Console"
|
||||
COM_MOKOBACKUP_FIELD_GDRIVE_CLIENT_SECRET="Google Client Secret"
|
||||
COM_MOKOBACKUP_FIELD_GDRIVE_REFRESH_TOKEN="Refresh Token"
|
||||
COM_MOKOBACKUP_FIELD_GDRIVE_REFRESH_TOKEN_DESC="OAuth 2.0 refresh token for offline access"
|
||||
COM_MOKOBACKUP_FIELD_GDRIVE_FOLDER_ID="Drive Folder ID"
|
||||
COM_MOKOBACKUP_FIELD_GDRIVE_FOLDER_ID_DESC="Google Drive folder ID where backups will be uploaded. Find this in the folder URL."
|
||||
|
||||
; Backup types
|
||||
COM_MOKOBACKUP_TYPE_FULL="Full Site (Database + Files)"
|
||||
COM_MOKOBACKUP_TYPE_DATABASE="Database Only"
|
||||
COM_MOKOBACKUP_TYPE_FILES="Files Only"
|
||||
COM_MOKOBACKUP_TYPE_DIFFERENTIAL="Differential (changed files + full DB)"
|
||||
|
||||
; Status labels
|
||||
COM_MOKOBACKUP_STATUS_COMPLETE="Complete"
|
||||
COM_MOKOBACKUP_STATUS_RUNNING="Running"
|
||||
COM_MOKOBACKUP_STATUS_FAIL="Failed"
|
||||
COM_MOKOBACKUP_STATUS_PENDING="Pending"
|
||||
|
||||
; Filters
|
||||
COM_MOKOBACKUP_FILTER_SEARCH="Search"
|
||||
COM_MOKOBACKUP_FILTER_STATUS="Status"
|
||||
COM_MOKOBACKUP_FILTER_STATUS_ALL="- Select Status -"
|
||||
|
||||
; Tabs and fieldsets
|
||||
COM_MOKOBACKUP_TAB_GENERAL="General"
|
||||
COM_MOKOBACKUP_TAB_ARCHIVE="Archive Settings"
|
||||
COM_MOKOBACKUP_TAB_FILTERS="Exclusion Filters"
|
||||
COM_MOKOBACKUP_TAB_REMOTE="Remote Storage"
|
||||
COM_MOKOBACKUP_FIELDSET_GENERAL="General"
|
||||
COM_MOKOBACKUP_FIELDSET_ARCHIVE="Archive Settings"
|
||||
COM_MOKOBACKUP_FIELDSET_STATUS="Status"
|
||||
COM_MOKOBACKUP_FIELDSET_FILTERS="Exclusion Filters"
|
||||
COM_MOKOBACKUP_FIELDSET_REMOTE="Remote Storage"
|
||||
COM_MOKOBACKUP_FIELDSET_FTP="FTP Settings"
|
||||
COM_MOKOBACKUP_FIELDSET_GDRIVE="Google Drive Settings"
|
||||
|
||||
; Backup profile selector
|
||||
COM_MOKOBACKUP_BACKUP_PROFILE="Backup Profile"
|
||||
|
||||
; Restore
|
||||
COM_MOKOBACKUP_TOOLBAR_RESTORE="Restore"
|
||||
COM_MOKOBACKUP_RESTORE_CONFIRM="WARNING: Restoring will overwrite your current site files and/or database. Are you sure you want to continue?"
|
||||
|
||||
; Notifications
|
||||
COM_MOKOBACKUP_TAB_NOTIFICATIONS="Notifications"
|
||||
COM_MOKOBACKUP_FIELDSET_NOTIFICATIONS="Email Notifications"
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_EMAIL="Notification Email(s)"
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses to notify. Leave empty to disable notifications."
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_SUCCESS="Notify on Success"
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_SUCCESS_DESC="Send an email when a backup completes successfully."
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_FAILURE="Notify on Failure"
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_FAILURE_DESC="Send an email when a backup fails. Includes log excerpt for debugging."
|
||||
|
||||
; Integrity verification
|
||||
COM_MOKOBACKUP_TOOLBAR_VERIFY="Verify Integrity"
|
||||
COM_MOKOBACKUP_VERIFY_OK="Archive integrity verified — SHA-256 checksum matches."
|
||||
COM_MOKOBACKUP_VERIFY_FAILED="INTEGRITY CHECK FAILED — archive has been modified or corrupted since backup."
|
||||
COM_MOKOBACKUP_VERIFY_NO_CHECKSUM="No checksum stored for this backup. Only backups created after this update can be verified."
|
||||
|
||||
; S3 storage
|
||||
COM_MOKOBACKUP_REMOTE_S3="Amazon S3 / S3-Compatible"
|
||||
COM_MOKOBACKUP_FIELDSET_S3="S3 Storage Settings"
|
||||
COM_MOKOBACKUP_FIELD_S3_ENDPOINT="S3 Endpoint"
|
||||
COM_MOKOBACKUP_FIELD_S3_ENDPOINT_DESC="S3 API endpoint URL. Leave blank for AWS S3. For Wasabi, MinIO, Backblaze B2, enter their endpoint URL."
|
||||
COM_MOKOBACKUP_FIELD_S3_REGION="Region"
|
||||
COM_MOKOBACKUP_FIELD_S3_REGION_DESC="AWS region (e.g. us-east-1, eu-west-1). Required for AWS Signature V4."
|
||||
COM_MOKOBACKUP_FIELD_S3_ACCESS_KEY="Access Key"
|
||||
COM_MOKOBACKUP_FIELD_S3_SECRET_KEY="Secret Key"
|
||||
COM_MOKOBACKUP_FIELD_S3_BUCKET="Bucket Name"
|
||||
COM_MOKOBACKUP_FIELD_S3_BUCKET_DESC="S3 bucket name where backups will be stored."
|
||||
COM_MOKOBACKUP_FIELD_S3_PATH="Path Prefix"
|
||||
COM_MOKOBACKUP_FIELD_S3_PATH_DESC="Optional path prefix inside the bucket (e.g. /backups or /sites/mysite)."
|
||||
|
||||
; Akeeba Import
|
||||
COM_MOKOBACKUP_TOOLBAR_IMPORT_AKEEBA="Import from Akeeba"
|
||||
COM_MOKOBACKUP_AKEEBA_NOT_FOUND="Akeeba Backup tables not found. Is Akeeba Backup Pro installed?"
|
||||
|
||||
; Update site notice
|
||||
COM_MOKOBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
|
||||
COM_MOKOBACKUP_UPDATE_SITE_MISSING="MokoJoomBackup update site not found. Reinstall the package to register the update server."
|
||||
COM_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||
|
||||
; Component Options (config.xml)
|
||||
COM_MOKOBACKUP_CONFIG_GENERAL="General"
|
||||
COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR="Default Backup Directory"
|
||||
COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC="Default directory for backup archives, relative to Joomla root. Can be overridden per profile."
|
||||
COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE="Default Profile"
|
||||
COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE_DESC="Default backup profile used by quick actions and CLI when no profile is specified."
|
||||
COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE="Show Update Site Notice"
|
||||
COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC="Display the update site configuration notice on the Backup Records view."
|
||||
COM_MOKOBACKUP_CONFIG_CLEANUP="Cleanup Defaults"
|
||||
COM_MOKOBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)"
|
||||
COM_MOKOBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command."
|
||||
COM_MOKOBACKUP_CONFIG_MAX_BACKUPS="Max Backup Count"
|
||||
COM_MOKOBACKUP_CONFIG_MAX_BACKUPS_DESC="Default maximum number of completed backups to retain."
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFICATIONS="Notifications"
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL="Global Notification Email(s)"
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses for global backup notifications. Per-profile settings override this."
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS="Notify on Success"
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup completes successfully (unless overridden by profile)."
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure"
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)."
|
||||
|
||||
; Folder picker
|
||||
COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
|
||||
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
|
||||
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
|
||||
|
||||
; Exclude fields
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Use Data to skip row data (keeps structure), Structure to skip CREATE TABLE, or both to fully exclude."
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_DATA="Data"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure"
|
||||
COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name"
|
||||
|
||||
; User group notifications
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
|
||||
|
||||
; Dashboard warnings
|
||||
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
|
||||
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security."
|
||||
|
||||
; Errors
|
||||
COM_MOKOBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
||||
COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||
@@ -1,10 +0,0 @@
|
||||
; MokoJoomBackup — Component system language file (en-GB)
|
||||
; @package MokoJoomBackup
|
||||
; @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
COM_MOKOBACKUP="MokoJoomBackup"
|
||||
COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
|
||||
COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records"
|
||||
COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles"
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,69 +0,0 @@
|
||||
; MokoJoomBackup — Component language file (en-US)
|
||||
; @package MokoJoomBackup
|
||||
; @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
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"
|
||||
COM_MOKOBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
||||
COM_MOKOBACKUP_NO_PROFILES="No backup profiles found."
|
||||
COM_MOKOBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
|
||||
COM_MOKOBACKUP_UPDATE_SITE_MISSING="MokoJoomBackup update site not found. Reinstall the package to register the update server."
|
||||
COM_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||
COM_MOKOBACKUP_CONFIG_GENERAL="General"
|
||||
COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR="Default Backup Directory"
|
||||
COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC="Default directory for backup archives, relative to Joomla root. Can be overridden per profile."
|
||||
COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE="Default Profile"
|
||||
COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE_DESC="Default backup profile used by quick actions and CLI when no profile is specified."
|
||||
COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE="Show Update Site Notice"
|
||||
COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC="Display the update site configuration notice on the Backup Records view."
|
||||
COM_MOKOBACKUP_CONFIG_CLEANUP="Cleanup Defaults"
|
||||
COM_MOKOBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)"
|
||||
COM_MOKOBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command."
|
||||
COM_MOKOBACKUP_CONFIG_MAX_BACKUPS="Max Backup Count"
|
||||
COM_MOKOBACKUP_CONFIG_MAX_BACKUPS_DESC="Default maximum number of completed backups to retain."
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFICATIONS="Notifications"
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL="Global Notification Email(s)"
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses for global backup notifications. Per-profile settings override this."
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS="Notify on Success"
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup completes successfully (unless overridden by profile)."
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure"
|
||||
COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)."
|
||||
COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
|
||||
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
|
||||
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
|
||||
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
|
||||
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security."
|
||||
COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
|
||||
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
|
||||
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Use Data to skip row data (keeps structure), Structure to skip CREATE TABLE, or both to fully exclude."
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_DATA="Data"
|
||||
COM_MOKOBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure"
|
||||
COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name"
|
||||
COM_MOKOBACKUP_VIEW_LOG="Backup Log"
|
||||
COM_MOKOBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
||||
COM_MOKOBACKUP_FIELD_PATH="File Path"
|
||||
COM_MOKOBACKUP_FIELD_DB_SIZE="DB Size"
|
||||
COM_MOKOBACKUP_FIELD_REMOTE="Remote Path"
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
|
||||
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
|
||||
@@ -1,10 +0,0 @@
|
||||
; MokoJoomBackup — Component system language file (en-US)
|
||||
; @package MokoJoomBackup
|
||||
; @author Moko Consulting <hello@mokoconsulting.tech>
|
||||
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
; @license GPL-3.0-or-later
|
||||
|
||||
COM_MOKOBACKUP="MokoJoomBackup"
|
||||
COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
|
||||
COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records"
|
||||
COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles"
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,66 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @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
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokobackup</name>
|
||||
<version>01.01.07-dev</version>
|
||||
<creationDate>2026-06-02</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>COM_MOKOBACKUP_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Component\MokoBackup</namespace>
|
||||
|
||||
<install>
|
||||
<sql>
|
||||
<file driver="mysql" charset="utf8">sql/install.mysql.sql</file>
|
||||
</sql>
|
||||
</install>
|
||||
|
||||
<uninstall>
|
||||
<sql>
|
||||
<file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file>
|
||||
</sql>
|
||||
</uninstall>
|
||||
|
||||
<update>
|
||||
<schemas>
|
||||
<schemapath type="mysql">sql/updates/mysql</schemapath>
|
||||
</schemas>
|
||||
</update>
|
||||
|
||||
<administration>
|
||||
<menu img="class:archive">COM_MOKOBACKUP</menu>
|
||||
<submenu>
|
||||
<menu link="option=com_mokobackup&view=dashboard" img="class:archive">COM_MOKOBACKUP_SUBMENU_DASHBOARD</menu>
|
||||
<menu link="option=com_mokobackup&view=backups" img="class:database">COM_MOKOBACKUP_SUBMENU_BACKUPS</menu>
|
||||
<menu link="option=com_mokobackup&view=profiles" img="class:cog">COM_MOKOBACKUP_SUBMENU_PROFILES</menu>
|
||||
</submenu>
|
||||
<files folder=".">
|
||||
<folder>cli</folder>
|
||||
<folder>forms</folder>
|
||||
<folder>services</folder>
|
||||
<folder>sql</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/com_mokobackup.ini</language>
|
||||
<language tag="en-GB">en-GB/com_mokobackup.sys.ini</language>
|
||||
</languages>
|
||||
</administration>
|
||||
|
||||
<api>
|
||||
<files folder="api">
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
</api>
|
||||
</extension>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,40 +0,0 @@
|
||||
<?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\Dispatcher\ComponentDispatcherFactoryInterface;
|
||||
use Joomla\CMS\Extension\ComponentInterface;
|
||||
use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
|
||||
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
|
||||
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
||||
use Joomla\Component\MokoBackup\Administrator\Extension\MokoBackupComponent;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->registerServiceProvider(new MVCFactory('\\Joomla\\Component\\MokoBackup'));
|
||||
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Joomla\\Component\\MokoBackup'));
|
||||
|
||||
$container->set(
|
||||
ComponentInterface::class,
|
||||
function (Container $container) {
|
||||
$component = new MokoBackupComponent(
|
||||
$container->get(ComponentDispatcherFactoryInterface::class)
|
||||
);
|
||||
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
|
||||
|
||||
return $component;
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,89 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`description` TEXT NOT NULL,
|
||||
`backup_type` VARCHAR(20) NOT NULL DEFAULT 'full' COMMENT 'full, database, files',
|
||||
`archive_format` VARCHAR(10) NOT NULL DEFAULT 'zip',
|
||||
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
|
||||
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
|
||||
`backup_dir` VARCHAR(512) NOT NULL DEFAULT 'administrator/components/com_mokobackup/backups',
|
||||
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders',
|
||||
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
|
||||
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
|
||||
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
|
||||
`remote_storage` VARCHAR(20) NOT NULL DEFAULT 'none' COMMENT 'none, ftp, google_drive, s3',
|
||||
`ftp_host` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`ftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 21,
|
||||
`ftp_username` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`ftp_password` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||
`ftp_passive` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`gdrive_folder_id` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`s3_endpoint` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'S3 endpoint URL (blank = AWS default)',
|
||||
`s3_region` VARCHAR(50) NOT NULL DEFAULT 'us-east-1',
|
||||
`s3_access_key` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`s3_secret_key` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`s3_bucket` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
|
||||
`include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive',
|
||||
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
|
||||
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
|
||||
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`published` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_published` (`published`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `#__mokobackup_records` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`profile_id` INT(11) UNSIGNED NOT NULL DEFAULT 1,
|
||||
`description` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending, running, complete, fail',
|
||||
`origin` VARCHAR(20) NOT NULL DEFAULT 'backend' COMMENT 'backend, cli, api, scheduled',
|
||||
`backup_type` VARCHAR(20) NOT NULL DEFAULT 'full' COMMENT 'full, database, files',
|
||||
`archivename` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`absolute_path` VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
`total_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`db_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`files_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`tables_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`multipart` INT(11) UNSIGNED NOT NULL DEFAULT 0,
|
||||
`tag` VARCHAR(50) NOT NULL DEFAULT '',
|
||||
`backupstart` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`backupend` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
`filesexist` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`remote_filename` VARCHAR(512) NOT NULL DEFAULT '',
|
||||
`checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'SHA-256 hash of archive',
|
||||
`base_record_id` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Base full backup ID for differential',
|
||||
`manifest` LONGTEXT DEFAULT NULL COMMENT 'JSON file manifest for differential comparison',
|
||||
`log` MEDIUMTEXT DEFAULT NULL COMMENT 'Step-by-step backup log',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_profile` (`profile_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_backupstart` (`backupstart`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert default backup profile
|
||||
INSERT INTO `#__mokobackup_profiles` (
|
||||
`id`, `title`, `description`, `backup_type`,
|
||||
`archive_format`, `compression_level`, `split_size`, `backup_dir`,
|
||||
`exclude_dirs`, `exclude_files`, `exclude_tables`,
|
||||
`published`, `ordering`, `created`, `modified`
|
||||
) VALUES (
|
||||
1, 'Default Backup Profile', 'Full site backup with default settings', 'full',
|
||||
'zip', 5, 0, 'administrator/components/com_mokobackup/backups',
|
||||
'administrator/components/com_mokobackup/backups\ntmp\ncache\nlogs\nadministrator/logs',
|
||||
'.gitignore\n.htaccess.bak',
|
||||
'#__session',
|
||||
1, 1, NOW(), NOW()
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,2 +0,0 @@
|
||||
DROP TABLE IF EXISTS `#__mokobackup_records`;
|
||||
DROP TABLE IF EXISTS `#__mokobackup_profiles`;
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
-- Initial release — no updates needed (tables created by install.mysql.sql)
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `#__mokobackup_profiles` CHANGE `include_kickstart` `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive';
|
||||
@@ -1,12 +0,0 @@
|
||||
-- MokoJoomBackup 01.01.02
|
||||
-- Consolidated schema updates: NULL defaults, notifications, archive name format
|
||||
|
||||
-- Fix: allow NULL defaults for manifest and log columns
|
||||
ALTER TABLE `#__mokobackup_records` MODIFY `manifest` LONGTEXT DEFAULT NULL;
|
||||
ALTER TABLE `#__mokobackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
|
||||
|
||||
-- Add user group notifications column to profiles
|
||||
ALTER TABLE `#__mokobackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
|
||||
|
||||
-- Add archive_name_format column with placeholder support
|
||||
ALTER TABLE `#__mokobackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,194 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* AJAX controller for step-based backups.
|
||||
* Handles init and step requests from the admin UI JavaScript.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoBackup\Administrator\Engine\SteppedBackupEngine;
|
||||
|
||||
class AjaxController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Initialize a new stepped backup.
|
||||
* POST: task=ajax.init&profile_id=1&description=...
|
||||
*/
|
||||
public function init(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$profileId = $this->input->getInt('profile_id', 1);
|
||||
$description = $this->input->getString('description', '');
|
||||
|
||||
$engine = new SteppedBackupEngine();
|
||||
$result = $engine->init($profileId, $description, 'backend');
|
||||
|
||||
$this->sendJson($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the next step of a backup session.
|
||||
* POST: task=ajax.step&session_id=mb_...
|
||||
*/
|
||||
public function step(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionId = $this->input->getString('session_id', '');
|
||||
|
||||
if (empty($sessionId)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing session_id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$engine = new SteppedBackupEngine();
|
||||
$result = $engine->runStep($sessionId);
|
||||
|
||||
$this->sendJson($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse server directories for the folder picker field.
|
||||
* POST: task=ajax.browseDir&path=/some/path
|
||||
*/
|
||||
public function browseDir(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $this->input->getString('path', JPATH_ROOT);
|
||||
$path = realpath($path) ?: $path;
|
||||
|
||||
if (!is_dir($path)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Directory not found: ' . $path]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: only allow browsing within JPATH_ROOT or parent directories
|
||||
// that could contain a backup folder (e.g., /home/user/backups)
|
||||
$dirs = [];
|
||||
$handle = @opendir($path);
|
||||
|
||||
if ($handle) {
|
||||
while (($entry = readdir($handle)) !== false) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullPath = $path . '/' . $entry;
|
||||
|
||||
if (is_dir($fullPath) && $entry[0] !== '.') {
|
||||
$dirs[] = [
|
||||
'name' => $entry,
|
||||
'path' => $fullPath,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
closedir($handle);
|
||||
}
|
||||
|
||||
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||
|
||||
$parent = dirname($path);
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'current' => $path,
|
||||
'parent' => ($parent !== $path) ? $parent : null,
|
||||
'dirs' => $dirs,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and return the log file contents for a backup record.
|
||||
* POST: task=ajax.viewLog&id=123
|
||||
*/
|
||||
public function viewLog(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
|
||||
if (!$id) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['absolute_path', 'log']))
|
||||
->from($db->quoteName('#__mokobackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
|
||||
if (!$record) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Record not found']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to load log from file alongside the archive
|
||||
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
|
||||
$logContent = '';
|
||||
|
||||
if (is_file($logPath)) {
|
||||
$logContent = file_get_contents($logPath);
|
||||
} elseif (!empty($record->log)) {
|
||||
// Fall back to database-stored log
|
||||
$logContent = $record->log;
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'log' => $logContent ?: '(no log available)',
|
||||
'source' => is_file($logPath) ? 'file' : 'database',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close the application.
|
||||
*/
|
||||
private function sendJson(array $data): void
|
||||
{
|
||||
$app = $this->app;
|
||||
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
$app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
$app->sendHeaders();
|
||||
|
||||
echo json_encode($data);
|
||||
|
||||
$app->close();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?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\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\FormController;
|
||||
|
||||
class BackupController extends FormController
|
||||
{
|
||||
protected $text_prefix = 'COM_MOKOBACKUP_BACKUP';
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
<?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\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\AdminController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine;
|
||||
use Joomla\Component\MokoBackup\Administrator\Engine\RestoreEngine;
|
||||
|
||||
class BackupsController extends AdminController
|
||||
{
|
||||
protected $text_prefix = 'COM_MOKOBACKUP_BACKUPS';
|
||||
|
||||
public function getModel($name = 'Backup', $prefix = 'Administrator', $config = ['ignore_request' => true])
|
||||
{
|
||||
return parent::getModel($name, $prefix, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new backup using the specified profile.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function start(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
$profileId = $this->input->getInt('profile_id', 1);
|
||||
$description = $this->input->getString('description', '');
|
||||
|
||||
$engine = new BackupEngine();
|
||||
$result = $engine->run($profileId, $description, 'backend');
|
||||
|
||||
if ($result['success']) {
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
$this->setMessage($result['message'], 'error');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a backup archive.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function download(): void
|
||||
{
|
||||
$id = $this->input->getInt('id', 0);
|
||||
$model = $this->getModel('Backup');
|
||||
$item = $model->getItem($id);
|
||||
|
||||
if (!$item || !$item->id || !$item->filesexist || !is_file($item->absolute_path)) {
|
||||
$this->setMessage('COM_MOKOBACKUP_ERROR_FILE_NOT_FOUND', 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush any output buffers to prevent HTML mixing with binary data
|
||||
while (@ob_end_clean()) {
|
||||
// clear all buffers
|
||||
}
|
||||
|
||||
$filename = basename($item->archivename);
|
||||
$filesize = filesize($item->absolute_path);
|
||||
|
||||
// Detect content type from file extension
|
||||
$contentType = str_ends_with($filename, '.tar.gz')
|
||||
? 'application/gzip'
|
||||
: 'application/zip';
|
||||
|
||||
header('Content-Type: ' . $contentType);
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Content-Length: ' . $filesize);
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
readfile($item->absolute_path);
|
||||
|
||||
$this->app->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore from a backup record.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function restore(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
$restoreFiles = (bool) $this->input->getInt('restore_files', 1);
|
||||
$restoreDb = (bool) $this->input->getInt('restore_db', 1);
|
||||
$preserveConfig = (bool) $this->input->getInt('preserve_config', 1);
|
||||
$password = $this->input->getString('encryption_password', '');
|
||||
|
||||
if (!$id) {
|
||||
$this->setMessage('COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED', 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$engine = new RestoreEngine();
|
||||
$result = $engine->restore($id, $restoreFiles, $restoreDb, $preserveConfig, $password);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
$this->setMessage($result['message'], 'error');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify integrity of a backup archive by re-computing SHA-256.
|
||||
*/
|
||||
public function verify(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
$cid = $this->input->get('cid', [], 'array');
|
||||
$id = !empty($cid) ? (int) $cid[0] : $this->input->getInt('id', 0);
|
||||
|
||||
if (!$id) {
|
||||
$this->setMessage('COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED', 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$model = $this->getModel('Backup');
|
||||
$item = $model->getItem($id);
|
||||
|
||||
if (!$item || !$item->id) {
|
||||
$this->setMessage('COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED', 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_file($item->absolute_path)) {
|
||||
$this->setMessage('COM_MOKOBACKUP_ERROR_FILE_NOT_FOUND', 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($item->checksum)) {
|
||||
$this->setMessage('COM_MOKOBACKUP_VERIFY_NO_CHECKSUM', 'warning');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$currentHash = hash_file('sha256', $item->absolute_path);
|
||||
|
||||
if ($currentHash === $item->checksum) {
|
||||
$this->setMessage('COM_MOKOBACKUP_VERIFY_OK');
|
||||
} else {
|
||||
$this->setMessage('COM_MOKOBACKUP_VERIFY_FAILED', 'error');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?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\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
|
||||
class DisplayController extends BaseController
|
||||
{
|
||||
protected $default_view = 'dashboard';
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?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\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\MVC\Controller\FormController;
|
||||
|
||||
class ProfileController extends FormController
|
||||
{
|
||||
protected $text_prefix = 'COM_MOKOBACKUP_PROFILE';
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
<?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\Controller;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Controller\AdminController;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\Component\MokoBackup\Administrator\Engine\AkeebaImporter;
|
||||
|
||||
class ProfilesController extends AdminController
|
||||
{
|
||||
protected $text_prefix = 'COM_MOKOBACKUP_PROFILES';
|
||||
|
||||
public function getModel($name = 'Profile', $prefix = 'Administrator', $config = ['ignore_request' => true])
|
||||
{
|
||||
return parent::getModel($name, $prefix, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import profiles and history from Akeeba Backup Pro, then disable Akeeba.
|
||||
*/
|
||||
public function importAkeeba(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
$importHistory = (bool) $this->input->getInt('import_history', 1);
|
||||
|
||||
$importer = new AkeebaImporter();
|
||||
$detection = $importer->detect();
|
||||
|
||||
if (!$detection['profiles']) {
|
||||
$this->setMessage('COM_MOKOBACKUP_AKEEBA_NOT_FOUND', 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=profiles', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $importer->import($importHistory);
|
||||
|
||||
if ($result['success']) {
|
||||
// Disable Akeeba Backup plugins and component after successful import
|
||||
$this->disableAkeeba();
|
||||
$this->setMessage($result['message']);
|
||||
} else {
|
||||
$this->setMessage($result['message'], 'error');
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokobackup&view=profiles', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all Akeeba Backup extensions (plugins + scheduled tasks).
|
||||
* Does NOT uninstall — the admin can do that manually if desired.
|
||||
*/
|
||||
private function disableAkeeba(): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Disable Akeeba plugins (system, quickicon, console, etc.)
|
||||
$akeebaElements = ['akeebabackup', 'akeeba', 'akeebabackuppro'];
|
||||
|
||||
foreach ($akeebaElements as $element) {
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__extensions'))
|
||||
->set($db->quoteName('enabled') . ' = 0')
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
|
||||
->where($db->quoteName('element') . ' LIKE ' . $db->quote('%' . $element . '%'));
|
||||
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
// Disable Akeeba scheduled tasks (if using Joomla task scheduler)
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__scheduler_tasks'))
|
||||
->where($db->quoteName('type') . ' LIKE ' . $db->quote('%akeeba%'));
|
||||
$db->setQuery($query);
|
||||
$taskIds = $db->loadColumn();
|
||||
|
||||
if (!empty($taskIds)) {
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__scheduler_tasks'))
|
||||
->set($db->quoteName('state') . ' = 0')
|
||||
->where($db->quoteName('id') . ' IN (' . implode(',', array_map('intval', $taskIds)) . ')');
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
'Akeeba Backup plugins and scheduled tasks have been disabled. You can uninstall Akeeba manually via Extensions > Manage.',
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,515 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Imports Akeeba Backup Pro profiles and backup history into MokoJoomBackup.
|
||||
*
|
||||
* Reads from #__ak_profiles and #__ak_stats, maps Akeeba's configuration
|
||||
* format to MokoJoomBackup's individual column format.
|
||||
*
|
||||
* Akeeba config format:
|
||||
* INI-style with dot-notation keys, e.g.:
|
||||
* akeeba.basic.backup_type=full
|
||||
* akeeba.advanced.archiver_engine=zip
|
||||
* akeeba.advanced.proc_engine=none
|
||||
*
|
||||
* Akeeba filter format:
|
||||
* JSON with structure like:
|
||||
* {"directories": {"include": [...], "exclude": [...]},
|
||||
* "files": {"include": [...], "exclude": [...]},
|
||||
* "databases": {"include": {...}, "exclude": {...}}}
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class AkeebaImporter
|
||||
{
|
||||
private array $log = [];
|
||||
|
||||
/**
|
||||
* Check if Akeeba Backup tables exist in the database.
|
||||
*
|
||||
* @return array{profiles: bool, stats: bool}
|
||||
*/
|
||||
public function detect(): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$tables = $db->getTableList();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
return [
|
||||
'profiles' => in_array($prefix . 'ak_profiles', $tables),
|
||||
'stats' => in_array($prefix . 'ak_stats', $tables),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview Akeeba profiles without importing.
|
||||
*
|
||||
* @return array List of Akeeba profiles with parsed settings
|
||||
*/
|
||||
public function preview(): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__ak_profiles'))
|
||||
->order($db->quoteName('id') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$akProfiles = $db->loadObjectList();
|
||||
|
||||
$previews = [];
|
||||
|
||||
foreach ($akProfiles as $akProfile) {
|
||||
$config = $this->parseAkeebaConfig($akProfile->configuration ?? '');
|
||||
$filters = $this->parseAkeebaFilters($akProfile->filters ?? '');
|
||||
|
||||
$previews[] = [
|
||||
'akeeba_id' => $akProfile->id,
|
||||
'title' => $akProfile->description,
|
||||
'backup_type' => $this->mapBackupType($config),
|
||||
'archive_format' => $this->mapArchiveFormat($config),
|
||||
'remote_storage' => $this->mapRemoteStorage($config),
|
||||
'exclude_dirs_count' => count($filters['exclude_dirs']),
|
||||
'exclude_tables_count' => count($filters['exclude_tables']),
|
||||
];
|
||||
}
|
||||
|
||||
return $previews;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import all Akeeba profiles into MokoJoomBackup.
|
||||
*
|
||||
* @param bool $importHistory Also import backup history from #__ak_stats
|
||||
*
|
||||
* @return array{success: bool, message: string, profiles_imported: int, records_imported: int}
|
||||
*/
|
||||
public function import(bool $importHistory = false): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$detection = $this->detect();
|
||||
|
||||
if (!$detection['profiles']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Akeeba Backup tables not found. Is Akeeba Backup Pro installed?',
|
||||
'profiles_imported' => 0,
|
||||
'records_imported' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Load Akeeba profiles
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__ak_profiles'))
|
||||
->order($db->quoteName('id') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$akProfiles = $db->loadObjectList();
|
||||
|
||||
$profilesImported = 0;
|
||||
$profileIdMap = []; // akeeba_id => mokobackup_id
|
||||
|
||||
foreach ($akProfiles as $akProfile) {
|
||||
$config = $this->parseAkeebaConfig($akProfile->configuration ?? '');
|
||||
$filters = $this->parseAkeebaFilters($akProfile->filters ?? '');
|
||||
|
||||
$mokoProfile = $this->mapToMokoProfile($akProfile, $config, $filters);
|
||||
|
||||
$db->insertObject('#__mokobackup_profiles', $mokoProfile, 'id');
|
||||
$profileIdMap[$akProfile->id] = $mokoProfile->id;
|
||||
$profilesImported++;
|
||||
|
||||
$this->log('Imported profile: "' . $akProfile->description . '" (Akeeba #' . $akProfile->id . ' → MokoBackup #' . $mokoProfile->id . ')');
|
||||
}
|
||||
|
||||
// Import backup history
|
||||
$recordsImported = 0;
|
||||
|
||||
if ($importHistory && $detection['stats']) {
|
||||
$recordsImported = $this->importHistory($profileIdMap);
|
||||
}
|
||||
|
||||
$this->log('Import complete: ' . $profilesImported . ' profiles, ' . $recordsImported . ' records');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Imported ' . $profilesImported . ' profiles' . ($recordsImported ? ' and ' . $recordsImported . ' backup records' : ''),
|
||||
'profiles_imported' => $profilesImported,
|
||||
'records_imported' => $recordsImported,
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Import backup history from #__ak_stats.
|
||||
*/
|
||||
private function importHistory(array $profileIdMap): int
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__ak_stats'))
|
||||
->order($db->quoteName('id') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$akStats = $db->loadObjectList();
|
||||
|
||||
$imported = 0;
|
||||
|
||||
foreach ($akStats as $stat) {
|
||||
$mokoProfileId = $profileIdMap[$stat->profile_id] ?? 1;
|
||||
|
||||
$statusMap = [
|
||||
'complete' => 'complete',
|
||||
'ok' => 'complete',
|
||||
'run' => 'running',
|
||||
'fail' => 'fail',
|
||||
'idle' => 'pending',
|
||||
];
|
||||
|
||||
$record = (object) [
|
||||
'profile_id' => $mokoProfileId,
|
||||
'description' => $stat->description ?? 'Imported from Akeeba #' . $stat->id,
|
||||
'status' => $statusMap[$stat->status ?? ''] ?? 'complete',
|
||||
'origin' => $stat->origin ?? 'backend',
|
||||
'backup_type' => $stat->type ?? 'full',
|
||||
'archivename' => $stat->archivename ?? '',
|
||||
'absolute_path' => $stat->absolute_path ?? '',
|
||||
'total_size' => (int) ($stat->total_size ?? 0),
|
||||
'db_size' => 0,
|
||||
'files_count' => 0,
|
||||
'tables_count' => 0,
|
||||
'multipart' => (int) ($stat->multipart ?? 0),
|
||||
'tag' => $stat->tag ?? '',
|
||||
'backupstart' => $stat->backupstart ?? '0000-00-00 00:00:00',
|
||||
'backupend' => $stat->backupend ?? '0000-00-00 00:00:00',
|
||||
'filesexist' => (int) ($stat->filesexist ?? 0),
|
||||
'remote_filename' => $stat->remote_filename ?? '',
|
||||
'log' => 'Imported from Akeeba Backup record #' . $stat->id,
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokobackup_records', $record, 'id');
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$this->log('Imported ' . $imported . ' backup records from Akeeba history');
|
||||
|
||||
return $imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an Akeeba profile to a MokoJoomBackup profile object.
|
||||
*/
|
||||
private function mapToMokoProfile(object $akProfile, array $config, array $filters): object
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
return (object) [
|
||||
'title' => $akProfile->description ?: 'Imported Profile #' . $akProfile->id,
|
||||
'description' => 'Imported from Akeeba Backup profile #' . $akProfile->id,
|
||||
'backup_type' => $this->mapBackupType($config),
|
||||
'archive_format' => $this->mapArchiveFormat($config),
|
||||
'compression_level' => $this->mapCompressionLevel($config),
|
||||
'split_size' => $this->mapSplitSize($config),
|
||||
'backup_dir' => $this->mapBackupDir($config),
|
||||
'exclude_dirs' => implode("\n", $filters['exclude_dirs']),
|
||||
'exclude_files' => implode("\n", $filters['exclude_files']),
|
||||
'exclude_tables' => implode("\n", $filters['exclude_tables']),
|
||||
'remote_storage' => $this->mapRemoteStorage($config),
|
||||
'ftp_host' => $config['engine.postproc.ftp.host'] ?? '',
|
||||
'ftp_port' => (int) ($config['engine.postproc.ftp.port'] ?? 21),
|
||||
'ftp_username' => $config['engine.postproc.ftp.user'] ?? '',
|
||||
'ftp_password' => $config['engine.postproc.ftp.pass'] ?? '',
|
||||
'ftp_path' => $config['engine.postproc.ftp.initial_directory'] ?? '/backups',
|
||||
'ftp_passive' => (int) ($config['engine.postproc.ftp.passive_mode'] ?? 1),
|
||||
'ftp_ssl' => (int) ($config['engine.postproc.ftp.ftps'] ?? 0),
|
||||
'gdrive_client_id' => $config['engine.postproc.googledrive.client_id'] ?? '',
|
||||
'gdrive_client_secret' => $config['engine.postproc.googledrive.client_secret'] ?? '',
|
||||
'gdrive_refresh_token' => $config['engine.postproc.googledrive.refresh_token'] ?? '',
|
||||
'gdrive_folder_id' => $config['engine.postproc.googledrive.directory'] ?? '',
|
||||
's3_endpoint' => $config['engine.postproc.s3.custom_endpoint'] ?? '',
|
||||
's3_region' => $config['engine.postproc.s3.region'] ?? 'us-east-1',
|
||||
's3_access_key' => $config['engine.postproc.s3.access_key'] ?? ($config['engine.postproc.s3.accesskey'] ?? ''),
|
||||
's3_secret_key' => $config['engine.postproc.s3.secret_key'] ?? ($config['engine.postproc.s3.secretkey'] ?? ''),
|
||||
's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '',
|
||||
's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups',
|
||||
'remote_keep_local' => 1,
|
||||
'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'),
|
||||
'published' => 1,
|
||||
'ordering' => (int) $akProfile->id,
|
||||
'created' => $now,
|
||||
'modified' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Akeeba's INI-format configuration into a flat key-value array.
|
||||
*
|
||||
* Akeeba uses a custom format:
|
||||
* akeeba.basic.backup_type=full
|
||||
* akeeba.advanced.archiver_engine=zip
|
||||
* engine.postproc.ftp.host=ftp.example.com
|
||||
*
|
||||
* Some versions use JSON-encoded configuration instead.
|
||||
*/
|
||||
private function parseAkeebaConfig(string $raw): array
|
||||
{
|
||||
$raw = trim($raw);
|
||||
|
||||
if (empty($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Try JSON first (newer Akeeba versions)
|
||||
$jsonData = json_decode($raw, true);
|
||||
|
||||
if (is_array($jsonData)) {
|
||||
return $this->flattenConfig($jsonData);
|
||||
}
|
||||
|
||||
// Fall back to INI-style parsing
|
||||
$config = [];
|
||||
$lines = explode("\n", str_replace("\r", '', $raw));
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if (empty($line) || $line[0] === ';' || $line[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pos = strpos($line, '=');
|
||||
|
||||
if ($pos === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = trim(substr($line, 0, $pos));
|
||||
$value = trim(substr($line, $pos + 1));
|
||||
|
||||
// Remove surrounding quotes
|
||||
if (strlen($value) >= 2 && $value[0] === '"' && $value[strlen($value) - 1] === '"') {
|
||||
$value = substr($value, 1, -1);
|
||||
}
|
||||
|
||||
$config[$key] = $value;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a nested JSON config into dot-notation keys.
|
||||
*/
|
||||
private function flattenConfig(array $data, string $prefix = ''): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
$fullKey = $prefix ? $prefix . '.' . $key : $key;
|
||||
|
||||
if (is_array($value) && !array_is_list($value)) {
|
||||
$result = array_merge($result, $this->flattenConfig($value, $fullKey));
|
||||
} else {
|
||||
$result[$fullKey] = is_array($value) ? json_encode($value) : (string) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Akeeba's filter configuration.
|
||||
*
|
||||
* Akeeba filters can be JSON or serialized PHP. Structure:
|
||||
* {"directories": ["/path1", "/path2"],
|
||||
* "files": ["file1.txt"],
|
||||
* "databases": {"tables": ["#__session"]},
|
||||
* "extradirs": [...],
|
||||
* "regexfiles": [...],
|
||||
* "regexdirectories": [...],
|
||||
* "regexdbtables": [...]}
|
||||
*
|
||||
* Also supports per-root format:
|
||||
* {"[SITEROOT]": {"directories": [...], "files": [...]}}
|
||||
*/
|
||||
private function parseAkeebaFilters(string $raw): array
|
||||
{
|
||||
$result = [
|
||||
'exclude_dirs' => [],
|
||||
'exclude_files' => [],
|
||||
'exclude_tables' => [],
|
||||
];
|
||||
|
||||
$raw = trim($raw);
|
||||
|
||||
if (empty($raw)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Try JSON
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
// Try unserialize (older Akeeba versions)
|
||||
$data = @unserialize($raw);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract directory exclusions
|
||||
$this->extractFilterValues($data, 'directories', $result['exclude_dirs']);
|
||||
$this->extractFilterValues($data, 'extradirs', $result['exclude_dirs']);
|
||||
|
||||
// Extract file exclusions
|
||||
$this->extractFilterValues($data, 'files', $result['exclude_files']);
|
||||
$this->extractFilterValues($data, 'skipfiles', $result['exclude_files']);
|
||||
|
||||
// Extract table exclusions
|
||||
$this->extractFilterValues($data, 'databases', $result['exclude_tables']);
|
||||
$this->extractFilterValues($data, 'tables', $result['exclude_tables']);
|
||||
$this->extractFilterValues($data, 'dbtables', $result['exclude_tables']);
|
||||
|
||||
// Handle per-root format: {"[SITEROOT]": {"directories": [...]}}
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value) && (str_contains($key, 'SITEROOT') || str_contains($key, 'SITEDB'))) {
|
||||
$this->extractFilterValues($value, 'directories', $result['exclude_dirs']);
|
||||
$this->extractFilterValues($value, 'files', $result['exclude_files']);
|
||||
$this->extractFilterValues($value, 'tables', $result['exclude_tables']);
|
||||
$this->extractFilterValues($value, 'databases', $result['exclude_tables']);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
$result['exclude_dirs'] = array_values(array_unique($result['exclude_dirs']));
|
||||
$result['exclude_files'] = array_values(array_unique($result['exclude_files']));
|
||||
$result['exclude_tables'] = array_values(array_unique($result['exclude_tables']));
|
||||
|
||||
// Clean up paths — remove SITEROOT prefix
|
||||
$result['exclude_dirs'] = array_map(function ($path) {
|
||||
$path = str_replace(['[SITEROOT]', '[SITEDB]'], '', $path);
|
||||
|
||||
return ltrim($path, '/\\');
|
||||
}, $result['exclude_dirs']);
|
||||
|
||||
$result['exclude_dirs'] = array_filter($result['exclude_dirs'], fn($v) => $v !== '');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively extract filter values from nested arrays.
|
||||
*/
|
||||
private function extractFilterValues(array $data, string $key, array &$target): void
|
||||
{
|
||||
if (isset($data[$key])) {
|
||||
$value = $data[$key];
|
||||
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $k => $v) {
|
||||
if (is_string($v) && !empty($v)) {
|
||||
$target[] = $v;
|
||||
} elseif (is_string($k) && !empty($k)) {
|
||||
$target[] = $k;
|
||||
}
|
||||
}
|
||||
} elseif (is_string($value) && !empty($value)) {
|
||||
$target[] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mapping methods ────────────────────────────────────────────
|
||||
|
||||
private function mapBackupType(array $config): string
|
||||
{
|
||||
$type = $config['akeeba.basic.backup_type'] ?? 'full';
|
||||
|
||||
return match ($type) {
|
||||
'dbonly' => 'database',
|
||||
'filesonly' => 'files',
|
||||
default => 'full',
|
||||
};
|
||||
}
|
||||
|
||||
private function mapArchiveFormat(array $config): string
|
||||
{
|
||||
$engine = $config['akeeba.advanced.archiver_engine'] ?? 'zip';
|
||||
|
||||
// We only support ZIP; map all Akeeba formats to zip
|
||||
return 'zip';
|
||||
}
|
||||
|
||||
private function mapCompressionLevel(array $config): int
|
||||
{
|
||||
$level = $config['engine.archiver.zip.compression_level'] ?? '';
|
||||
|
||||
if ($level === '' || $level === 'default') {
|
||||
return 5;
|
||||
}
|
||||
|
||||
$level = (int) $level;
|
||||
|
||||
return max(0, min(9, $level));
|
||||
}
|
||||
|
||||
private function mapSplitSize(array $config): int
|
||||
{
|
||||
$size = $config['engine.archiver.zip.part_size'] ?? 0;
|
||||
|
||||
if (empty($size)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Akeeba stores in bytes, we store in MB
|
||||
return max(0, (int) ($size / 1048576));
|
||||
}
|
||||
|
||||
private function mapBackupDir(array $config): string
|
||||
{
|
||||
$dir = $config['akeeba.basic.output_directory'] ?? '';
|
||||
|
||||
if (empty($dir) || $dir === '[DEFAULT_OUTPUT]') {
|
||||
return 'administrator/components/com_mokobackup/backups';
|
||||
}
|
||||
|
||||
// Convert absolute path to relative
|
||||
if (defined('JPATH_ROOT') && str_starts_with($dir, JPATH_ROOT)) {
|
||||
$dir = ltrim(substr($dir, strlen(JPATH_ROOT)), '/\\');
|
||||
}
|
||||
|
||||
return $dir ?: 'administrator/components/com_mokobackup/backups';
|
||||
}
|
||||
|
||||
private function mapRemoteStorage(array $config): string
|
||||
{
|
||||
$engine = $config['akeeba.advanced.proc_engine'] ?? 'none';
|
||||
|
||||
return match ($engine) {
|
||||
'ftp', 'ftpcurl', 'sftp', 'sftpcurl' => 'ftp',
|
||||
'googledrive' => 'google_drive',
|
||||
's3', 'amazons3' => 's3',
|
||||
'dropbox', 'onedrive', 'box' => 'none', // not yet supported
|
||||
default => 'none',
|
||||
};
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?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\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
interface ArchiverInterface
|
||||
{
|
||||
/**
|
||||
* Open or create the archive at the given path.
|
||||
*/
|
||||
public function open(string $path): void;
|
||||
|
||||
/**
|
||||
* Add a string as a file inside the archive.
|
||||
*/
|
||||
public function addFromString(string $localName, string $contents): void;
|
||||
|
||||
/**
|
||||
* Add a file from disk into the archive.
|
||||
*/
|
||||
public function addFile(string $filePath, string $localName): void;
|
||||
|
||||
/**
|
||||
* Finalize and close the archive.
|
||||
*/
|
||||
public function close(): void;
|
||||
|
||||
/**
|
||||
* Return the file extension for this archive type (e.g. 'zip', 'tar.gz').
|
||||
*/
|
||||
public function getExtension(): string;
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
<?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\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class BackupEngine
|
||||
{
|
||||
private string $backupDir;
|
||||
private array $log = [];
|
||||
|
||||
/**
|
||||
* Run a backup using the specified profile.
|
||||
*
|
||||
* @param int $profileId Profile ID to use
|
||||
* @param string $description Human-readable description
|
||||
* @param string $origin Origin: backend, cli, api, scheduled
|
||||
*
|
||||
* @return array{success: bool, message: string, record_id?: int}
|
||||
*/
|
||||
public function run(int $profileId, string $description, string $origin = 'backend'): array
|
||||
{
|
||||
// Override PHP limits for long-running backup operations
|
||||
$this->overridePhpLimits();
|
||||
|
||||
// Verify required extensions
|
||||
$extCheck = $this->checkRequiredExtensions();
|
||||
|
||||
if ($extCheck !== true) {
|
||||
return ['success' => false, 'message' => $extCheck];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load profile
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokobackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
|
||||
if (!$profile) {
|
||||
return ['success' => false, 'message' => 'Profile not found: ' . $profileId];
|
||||
}
|
||||
|
||||
// Read settings directly from profile columns
|
||||
$excludeDirs = $this->parseNewlineList($profile->exclude_dirs ?? '');
|
||||
$excludeFiles = $this->parseNewlineList($profile->exclude_files ?? '');
|
||||
$excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
|
||||
|
||||
// Resolve placeholders in directory and filename
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
|
||||
$configuredDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups';
|
||||
$this->backupDir = $this->resolveBackupDir($resolver->resolve($configuredDir));
|
||||
|
||||
if (!is_dir($this->backupDir)) {
|
||||
mkdir($this->backupDir, 0755, true);
|
||||
}
|
||||
|
||||
// Create backup record
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$archiveFormat = $profile->archive_format ?? 'zip';
|
||||
$archiver = $this->createArchiver($archiveFormat);
|
||||
$archiveExt = $archiver->getExtension();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
|
||||
|
||||
if (empty($description)) {
|
||||
$description = $profile->title . ' — ' . $now;
|
||||
}
|
||||
|
||||
$record = (object) [
|
||||
'profile_id' => $profileId,
|
||||
'description' => $description,
|
||||
'status' => 'running',
|
||||
'origin' => $origin,
|
||||
'backup_type' => $profile->backup_type,
|
||||
'archivename' => $archiveName,
|
||||
'absolute_path' => $this->backupDir . '/' . $archiveName,
|
||||
'total_size' => 0,
|
||||
'db_size' => 0,
|
||||
'files_count' => 0,
|
||||
'tables_count' => 0,
|
||||
'multipart' => 0,
|
||||
'tag' => $tag,
|
||||
'backupstart' => $now,
|
||||
'backupend' => '0000-00-00 00:00:00',
|
||||
'filesexist' => 0,
|
||||
'remote_filename' => '',
|
||||
'log' => '',
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokobackup_records', $record, 'id');
|
||||
$recordId = $record->id;
|
||||
|
||||
try {
|
||||
$this->log('Backup started: ' . $description);
|
||||
$archivePath = $this->backupDir . '/' . $archiveName;
|
||||
|
||||
// Create archive
|
||||
$archiver->open($archivePath);
|
||||
|
||||
$dbSize = 0;
|
||||
$filesCount = 0;
|
||||
$tablesCount = 0;
|
||||
|
||||
// Step 1: Database dump (unless files-only)
|
||||
if ($profile->backup_type !== 'files') {
|
||||
$this->log('Starting database dump...');
|
||||
$dumper = new DatabaseDumper($excludeTables);
|
||||
$sqlDump = $dumper->dump();
|
||||
$archiver->addFromString('database.sql', $sqlDump);
|
||||
$dbSize = strlen($sqlDump);
|
||||
$tablesCount = $dumper->getTablesCount();
|
||||
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
|
||||
}
|
||||
|
||||
// Step 2: Files (unless database-only)
|
||||
$manifest = [];
|
||||
|
||||
if ($profile->backup_type !== 'database') {
|
||||
$this->log('Starting file scan...');
|
||||
$scanner = new FileScanner(JPATH_ROOT, $excludeDirs, $excludeFiles);
|
||||
$allFiles = $scanner->scan();
|
||||
|
||||
// Differential: only include changed files
|
||||
if ($profile->backup_type === 'differential') {
|
||||
$baseManifest = $this->loadBaseManifest($db, $profileId);
|
||||
|
||||
if (empty($baseManifest)) {
|
||||
$this->log('No base full backup found — running full backup instead');
|
||||
$filesToBackup = $allFiles;
|
||||
} else {
|
||||
$filesToBackup = DifferentialScanner::getChangedFiles($allFiles, $baseManifest, JPATH_ROOT);
|
||||
$this->log('Differential: ' . count($filesToBackup) . ' changed files out of ' . count($allFiles) . ' total');
|
||||
}
|
||||
} else {
|
||||
$filesToBackup = $allFiles;
|
||||
}
|
||||
|
||||
$filesCount = count($filesToBackup);
|
||||
$this->log('Backing up ' . $filesCount . ' files');
|
||||
|
||||
foreach ($filesToBackup as $relativePath) {
|
||||
$fullPath = JPATH_ROOT . '/' . $relativePath;
|
||||
|
||||
if (is_file($fullPath) && is_readable($fullPath)) {
|
||||
$archiver->addFile($fullPath, $relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
$this->log('Files added to archive');
|
||||
|
||||
// Build manifest for full/differential backups (used by future differentials)
|
||||
if ($profile->backup_type === 'full' || ($profile->backup_type === 'differential' && empty($baseManifest))) {
|
||||
$manifest = DifferentialScanner::buildManifest($allFiles, JPATH_ROOT);
|
||||
$this->log('File manifest built: ' . count($manifest) . ' entries');
|
||||
}
|
||||
}
|
||||
|
||||
$archiver->close();
|
||||
|
||||
// Step 1.5: Apply AES-256 encryption (if configured)
|
||||
$encryptionPassword = $profile->encryption_password ?? '';
|
||||
|
||||
if (!empty($encryptionPassword)) {
|
||||
if ($archiveFormat !== 'zip') {
|
||||
$this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
|
||||
} else {
|
||||
$this->log('Encrypting archive with AES-256...');
|
||||
$this->encryptArchive($archivePath, $encryptionPassword);
|
||||
$this->log('Archive encrypted');
|
||||
}
|
||||
}
|
||||
|
||||
// Record archive size and compute checksum (after encryption)
|
||||
$totalSize = file_exists($archivePath) ? filesize($archivePath) : 0;
|
||||
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
||||
$checksum = is_file($archivePath) ? hash_file('sha256', $archivePath) : '';
|
||||
$this->log('Archive created: ' . $sizeHuman);
|
||||
$this->log('SHA-256: ' . ($checksum ?: 'N/A'));
|
||||
|
||||
// Step 2.5: Wrap with MokoRestore script (if enabled)
|
||||
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
||||
|
||||
if ($includeMokoRestore) {
|
||||
$this->log('Wrapping with MokoRestore script...');
|
||||
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
||||
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
||||
MokoRestore::wrap($archivePath, $mokoRestorePath);
|
||||
|
||||
// Replace the original archive with the wrapped one
|
||||
@unlink($archivePath);
|
||||
rename($mokoRestorePath, $archivePath);
|
||||
$totalSize = filesize($archivePath);
|
||||
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
||||
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
||||
}
|
||||
|
||||
$remoteFilename = '';
|
||||
|
||||
// Step 3: Remote upload (if configured)
|
||||
$remoteStorage = $profile->remote_storage ?? 'none';
|
||||
|
||||
if ($remoteStorage !== 'none') {
|
||||
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
||||
$uploader = $this->createUploader($remoteStorage, $profile);
|
||||
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
||||
|
||||
if ($uploadResult['success']) {
|
||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||
|
||||
// Delete local copy if configured
|
||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
$this->log('Local copy removed (remote_keep_local = off)');
|
||||
}
|
||||
} else {
|
||||
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
||||
$this->log('Local backup is preserved.');
|
||||
}
|
||||
}
|
||||
|
||||
// Write log file alongside the archive
|
||||
$logContent = implode("\n", $this->log);
|
||||
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
|
||||
@file_put_contents($logPath, $logContent);
|
||||
|
||||
// Final record update
|
||||
$update = (object) [
|
||||
'id' => $recordId,
|
||||
'status' => 'complete',
|
||||
'total_size' => $totalSize,
|
||||
'db_size' => $dbSize,
|
||||
'files_count' => $filesCount,
|
||||
'tables_count' => $tablesCount,
|
||||
'backupend' => date('Y-m-d H:i:s'),
|
||||
'filesexist' => is_file($archivePath) ? 1 : 0,
|
||||
'remote_filename' => $remoteFilename,
|
||||
'checksum' => $checksum,
|
||||
'manifest' => !empty($manifest) ? json_encode($manifest) : '',
|
||||
'log' => $logContent,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokobackup_records', $update, 'id');
|
||||
|
||||
// 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 . ')',
|
||||
'record_id' => $recordId,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$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),
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokobackup_records', $update, 'id');
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override PHP execution limits for backup operations.
|
||||
* Attempts multiple methods since some hosts restrict ini_set.
|
||||
*/
|
||||
private function overridePhpLimits(): void
|
||||
{
|
||||
// Remove execution time limit (0 = unlimited)
|
||||
@set_time_limit(0);
|
||||
@ini_set('max_execution_time', '0');
|
||||
|
||||
// Increase memory limit for large sites
|
||||
$currentMemory = $this->parseBytes(ini_get('memory_limit'));
|
||||
|
||||
if ($currentMemory > 0 && $currentMemory < 512 * 1024 * 1024) {
|
||||
@ini_set('memory_limit', '512M');
|
||||
}
|
||||
|
||||
// Disable output buffering to prevent memory buildup
|
||||
while (@ob_end_clean()) {
|
||||
// flush all output buffers
|
||||
}
|
||||
|
||||
// Prevent browser/proxy timeout by disabling compression
|
||||
@ini_set('zlib.output_compression', 'Off');
|
||||
|
||||
// Ignore user abort so backup completes even if browser closes
|
||||
@ignore_user_abort(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a PHP ini byte value (e.g. "128M") into bytes.
|
||||
*/
|
||||
private function parseBytes(string $value): int
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '-1' || $value === '') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$unit = strtolower(substr($value, -1));
|
||||
$num = (int) $value;
|
||||
|
||||
return match ($unit) {
|
||||
'g' => $num * 1024 * 1024 * 1024,
|
||||
'm' => $num * 1024 * 1024,
|
||||
'k' => $num * 1024,
|
||||
default => $num,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify required PHP extensions are loaded.
|
||||
*
|
||||
* @return true|string True if all ok, or error message string
|
||||
*/
|
||||
private function checkRequiredExtensions(): true|string
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
if (!extension_loaded('zip')) {
|
||||
$missing[] = 'ext-zip (required for archive creation)';
|
||||
}
|
||||
|
||||
if (!extension_loaded('mbstring') && !function_exists('mb_strlen')) {
|
||||
$missing[] = 'ext-mbstring (required for binary-safe operations)';
|
||||
}
|
||||
|
||||
if (!empty($missing)) {
|
||||
return 'Missing PHP extensions: ' . implode(', ', $missing);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the appropriate archiver based on the archive format.
|
||||
*/
|
||||
private function createArchiver(string $format): ArchiverInterface
|
||||
{
|
||||
return match ($format) {
|
||||
'zip' => new ZipArchiver(),
|
||||
'tar.gz' => new TarGzArchiver(),
|
||||
default => new ZipArchiver(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the appropriate remote uploader based on the storage type.
|
||||
*/
|
||||
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
||||
{
|
||||
return match ($type) {
|
||||
'ftp' => new FtpUploader($profile),
|
||||
'google_drive' => new GoogleDriveUploader($profile),
|
||||
's3' => new S3Uploader($profile),
|
||||
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the file manifest from the most recent full backup for this profile.
|
||||
* Used by differential backups to determine which files changed.
|
||||
*/
|
||||
private function loadBaseManifest(object $db, int $profileId): array
|
||||
{
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('manifest'))
|
||||
->from($db->quoteName('#__mokobackup_records'))
|
||||
->where($db->quoteName('profile_id') . ' = ' . $profileId)
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||
->where($db->quoteName('manifest') . ' != ' . $db->quote(''))
|
||||
->where($db->quoteName('backup_type') . ' = ' . $db->quote('full'))
|
||||
->order($db->quoteName('backupstart') . ' DESC');
|
||||
$db->setQuery($query, 0, 1);
|
||||
$manifestJson = $db->loadResult();
|
||||
|
||||
if (empty($manifestJson)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return json_decode($manifestJson, true) ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a ZIP archive using AES-256.
|
||||
*
|
||||
* Uses ZipArchive::setEncryptionName() (PHP 7.2+) which produces
|
||||
* WinZip-compatible AES-256 encrypted archives. Falls back to
|
||||
* re-creating the archive with per-file encryption if needed.
|
||||
*/
|
||||
private function encryptArchive(string $archivePath, string $password): void
|
||||
{
|
||||
if (!defined('ZipArchive::EM_AES_256')) {
|
||||
throw new \RuntimeException(
|
||||
'AES-256 ZIP encryption requires PHP 7.2+ compiled with libzip 1.2.0+. '
|
||||
. 'Your PHP installation does not support ZipArchive::EM_AES_256.'
|
||||
);
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($archivePath) !== true) {
|
||||
throw new \RuntimeException('Cannot open archive for encryption');
|
||||
}
|
||||
|
||||
$zip->setPassword($password);
|
||||
|
||||
$numFiles = $zip->numFiles;
|
||||
|
||||
for ($i = 0; $i < $numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
|
||||
if ($name === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$zip->setEncryptionName($name, \ZipArchive::EM_AES_256);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a newline-separated text field into an array of trimmed, non-empty strings.
|
||||
*/
|
||||
private function parseNewlineList(string $text): array
|
||||
{
|
||||
if (empty($text)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map('trim', explode("\n", str_replace("\r", '', $text))),
|
||||
fn($line) => $line !== ''
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a backup directory path. Absolute paths are used as-is,
|
||||
* relative paths are resolved from JPATH_ROOT.
|
||||
*/
|
||||
private function resolveBackupDir(string $dir): string
|
||||
{
|
||||
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
|
||||
return rtrim($dir, '/\\');
|
||||
}
|
||||
|
||||
return JPATH_ROOT . '/' . $dir;
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
<?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\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class DatabaseDumper
|
||||
{
|
||||
/** @var array Tables to exclude entirely (both structure and data) */
|
||||
private array $excludeBoth = [];
|
||||
|
||||
/** @var array Tables to exclude data only (structure is kept) */
|
||||
private array $excludeDataOnly = [];
|
||||
|
||||
/** @var array Tables to exclude structure only (data is kept — unusual) */
|
||||
private array $excludeStructureOnly = [];
|
||||
|
||||
private int $tablesCount = 0;
|
||||
|
||||
/**
|
||||
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
||||
* Supports suffixes: :data-only, :structure-only.
|
||||
* No suffix = exclude both (backward compatible).
|
||||
*/
|
||||
public function __construct(array $excludeTables = [])
|
||||
{
|
||||
foreach ($excludeTables as $entry) {
|
||||
if (str_ends_with($entry, ':data-only')) {
|
||||
$this->excludeDataOnly[] = substr($entry, 0, -10);
|
||||
} elseif (str_ends_with($entry, ':structure-only')) {
|
||||
$this->excludeStructureOnly[] = substr($entry, 0, -15);
|
||||
} else {
|
||||
$this->excludeBoth[] = $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump all database tables to SQL.
|
||||
*
|
||||
* @return string The SQL dump
|
||||
*/
|
||||
public function dump(): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$prefix = $db->getPrefix();
|
||||
$output = [];
|
||||
|
||||
$output[] = '-- MokoJoomBackup Database Dump';
|
||||
$output[] = '-- Generated: ' . date('Y-m-d H:i:s');
|
||||
$output[] = '-- Server: ' . $db->getServerType();
|
||||
$output[] = '-- Database: ' . $db->getName();
|
||||
$output[] = '-- Prefix: ' . $prefix;
|
||||
$output[] = '';
|
||||
$output[] = 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";';
|
||||
$output[] = 'SET time_zone = "+00:00";';
|
||||
$output[] = '';
|
||||
|
||||
// Get all tables with the site prefix
|
||||
$tables = $db->getTableList();
|
||||
$siteTables = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (str_starts_with($table, $prefix)) {
|
||||
$siteTables[] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($siteTables as $table) {
|
||||
// Check if excluded
|
||||
$abstractName = '#__' . substr($table, strlen($prefix));
|
||||
|
||||
if ($this->isExcludedBoth($abstractName, $table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$skipData = $this->isExcludedDataOnly($abstractName, $table);
|
||||
$skipStructure = $this->isExcludedStructureOnly($abstractName, $table);
|
||||
|
||||
$this->tablesCount++;
|
||||
|
||||
$output[] = '-- --------------------------------------------------------';
|
||||
$output[] = '-- Table: ' . $table;
|
||||
|
||||
if ($skipData) {
|
||||
$output[] = '-- (data excluded)';
|
||||
}
|
||||
|
||||
if ($skipStructure) {
|
||||
$output[] = '-- (structure excluded)';
|
||||
}
|
||||
|
||||
$output[] = '-- --------------------------------------------------------';
|
||||
$output[] = '';
|
||||
|
||||
// Get CREATE TABLE statement (unless structure is excluded)
|
||||
if (!$skipStructure) {
|
||||
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
|
||||
$createRow = $db->loadRow();
|
||||
|
||||
if (!$createRow || empty($createRow[1])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
|
||||
$output[] = $createRow[1] . ';';
|
||||
$output[] = '';
|
||||
}
|
||||
|
||||
// Dump data (unless data is excluded)
|
||||
if ($skipData) {
|
||||
$output[] = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
|
||||
$rowCount = (int) $db->loadResult();
|
||||
|
||||
if ($rowCount === 0) {
|
||||
$output[] = '-- (empty table)';
|
||||
$output[] = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$chunkSize = 500;
|
||||
|
||||
for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName($table)),
|
||||
$offset,
|
||||
$chunkSize
|
||||
);
|
||||
$rows = $db->loadAssocList();
|
||||
|
||||
if (empty($rows)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$values = [];
|
||||
|
||||
foreach ($row as $value) {
|
||||
if ($value === null) {
|
||||
$values[] = 'NULL';
|
||||
} else {
|
||||
$values[] = $db->quote($value);
|
||||
}
|
||||
}
|
||||
|
||||
$columns = array_map([$db, 'quoteName'], array_keys($row));
|
||||
$output[] = 'INSERT INTO ' . $db->quoteName($table)
|
||||
. ' (' . implode(', ', $columns) . ')'
|
||||
. ' VALUES (' . implode(', ', $values) . ');';
|
||||
}
|
||||
}
|
||||
|
||||
$output[] = '';
|
||||
}
|
||||
|
||||
return implode("\n", $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a table is fully excluded (both data and structure).
|
||||
*/
|
||||
private function isExcludedBoth(string $abstractName, string $realName): bool
|
||||
{
|
||||
foreach ($this->excludeBoth as $pattern) {
|
||||
if ($pattern === $abstractName || $pattern === $realName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a table's data is excluded (structure only).
|
||||
*/
|
||||
private function isExcludedDataOnly(string $abstractName, string $realName): bool
|
||||
{
|
||||
foreach ($this->excludeDataOnly as $pattern) {
|
||||
if ($pattern === $abstractName || $pattern === $realName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a table's structure is excluded (data only).
|
||||
*/
|
||||
private function isExcludedStructureOnly(string $abstractName, string $realName): bool
|
||||
{
|
||||
foreach ($this->excludeStructureOnly as $pattern) {
|
||||
if ($pattern === $abstractName || $pattern === $realName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getTablesCount(): int
|
||||
{
|
||||
return $this->tablesCount;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Imports a SQL dump file created by DatabaseDumper.
|
||||
* Handles #__ prefix replacement, multi-statement execution,
|
||||
* and DROP TABLE before CREATE TABLE for clean restores.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class DatabaseImporter
|
||||
{
|
||||
/**
|
||||
* Import a SQL dump file into the database.
|
||||
*
|
||||
* @param string $sqlFile Absolute path to the SQL dump file
|
||||
*
|
||||
* @return int Number of statements executed
|
||||
*
|
||||
* @throws \RuntimeException On import failure
|
||||
*/
|
||||
public function import(string $sqlFile): int
|
||||
{
|
||||
if (!is_file($sqlFile) || !is_readable($sqlFile)) {
|
||||
throw new \RuntimeException('SQL file not readable: ' . $sqlFile);
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
$handle = fopen($sqlFile, 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Cannot open SQL file: ' . $sqlFile);
|
||||
}
|
||||
|
||||
$statementsExecuted = 0;
|
||||
$currentStatement = '';
|
||||
$inMultiLineComment = false;
|
||||
|
||||
try {
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
// Skip empty lines
|
||||
if ($trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip single-line comments
|
||||
if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle multi-line comments
|
||||
if (str_starts_with($trimmed, '/*')) {
|
||||
$inMultiLineComment = true;
|
||||
}
|
||||
|
||||
if ($inMultiLineComment) {
|
||||
if (str_contains($trimmed, '*/')) {
|
||||
$inMultiLineComment = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accumulate the statement
|
||||
$currentStatement .= $line;
|
||||
|
||||
// Check if statement is complete (ends with semicolon)
|
||||
if (str_ends_with($trimmed, ';')) {
|
||||
$statement = trim($currentStatement);
|
||||
$currentStatement = '';
|
||||
|
||||
if (empty($statement)) {
|
||||
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.
|
||||
|
||||
try {
|
||||
$db->setQuery($statement);
|
||||
$db->execute();
|
||||
$statementsExecuted++;
|
||||
} catch (\Exception $e) {
|
||||
// Log but don't abort — some statements may fail on
|
||||
// different MySQL versions (e.g. charset differences)
|
||||
// but the overall restore should continue.
|
||||
error_log('MokoBackup SQL import warning: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute any remaining statement without trailing semicolon
|
||||
$remaining = trim($currentStatement);
|
||||
|
||||
if (!empty($remaining)) {
|
||||
try {
|
||||
$db->setQuery($remaining);
|
||||
$db->execute();
|
||||
$statementsExecuted++;
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoBackup SQL import warning (final): ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
return $statementsExecuted;
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Differential file scanner — compares current filesystem against a
|
||||
* stored manifest from the last full backup. Only returns files that
|
||||
* are new or modified since the base backup.
|
||||
*
|
||||
* Manifest format (JSON):
|
||||
* {"path/to/file": {"size": 1234, "mtime": 1717350000}, ...}
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class DifferentialScanner
|
||||
{
|
||||
/**
|
||||
* Build a file manifest for the current state of the site.
|
||||
*
|
||||
* @param string[] $filePaths Array of relative file paths (from FileScanner)
|
||||
* @param string $rootDir Joomla root directory
|
||||
*
|
||||
* @return array<string, array{size: int, mtime: int}>
|
||||
*/
|
||||
public static function buildManifest(array $filePaths, string $rootDir): array
|
||||
{
|
||||
$manifest = [];
|
||||
|
||||
foreach ($filePaths as $relativePath) {
|
||||
$fullPath = rtrim($rootDir, '/') . '/' . $relativePath;
|
||||
|
||||
if (!is_file($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifest[$relativePath] = [
|
||||
'size' => (int) filesize($fullPath),
|
||||
'mtime' => (int) filemtime($fullPath),
|
||||
];
|
||||
}
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare current files against a base manifest and return only changed/new files.
|
||||
*
|
||||
* @param array $currentFiles Array of relative file paths from FileScanner
|
||||
* @param array $baseManifest Manifest from the base full backup
|
||||
* @param string $rootDir Joomla root directory
|
||||
*
|
||||
* @return string[] Array of relative paths that are new or modified
|
||||
*/
|
||||
public static function getChangedFiles(array $currentFiles, array $baseManifest, string $rootDir): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
foreach ($currentFiles as $relativePath) {
|
||||
$fullPath = rtrim($rootDir, '/') . '/' . $relativePath;
|
||||
|
||||
if (!is_file($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// New file — not in base manifest
|
||||
if (!isset($baseManifest[$relativePath])) {
|
||||
$changed[] = $relativePath;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if modified (size or mtime changed)
|
||||
$currentSize = (int) filesize($fullPath);
|
||||
$currentMtime = (int) filemtime($fullPath);
|
||||
$baseEntry = $baseManifest[$relativePath];
|
||||
|
||||
if ($currentSize !== $baseEntry['size'] || $currentMtime !== $baseEntry['mtime']) {
|
||||
$changed[] = $relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Restores files from a staging directory to the Joomla root.
|
||||
* Skips database.sql and sensitive files that should not be overwritten.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class FileRestorer
|
||||
{
|
||||
private string $sourceDir;
|
||||
private string $targetDir;
|
||||
|
||||
/**
|
||||
* Files that should never be overwritten during restore.
|
||||
* configuration.php is handled separately by the RestoreEngine.
|
||||
*/
|
||||
private const SKIP_FILES = [
|
||||
'configuration.php',
|
||||
'.htaccess',
|
||||
'web.config',
|
||||
];
|
||||
|
||||
/**
|
||||
* Files that are backup artifacts, not part of the site.
|
||||
*/
|
||||
private const EXCLUDE_FILES = [
|
||||
'database.sql',
|
||||
];
|
||||
|
||||
public function __construct(string $sourceDir, string $targetDir)
|
||||
{
|
||||
$this->sourceDir = rtrim($sourceDir, '/\\');
|
||||
$this->targetDir = rtrim($targetDir, '/\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy files from staging to target, preserving directory structure.
|
||||
*
|
||||
* @return int Number of files restored
|
||||
*/
|
||||
public function restore(): int
|
||||
{
|
||||
$count = 0;
|
||||
$this->restoreDirectory('', $count);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function restoreDirectory(string $relativePath, int &$count): void
|
||||
{
|
||||
$sourcePath = $this->sourceDir . ($relativePath ? '/' . $relativePath : '');
|
||||
|
||||
if (!is_dir($sourcePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$handle = opendir($sourcePath);
|
||||
|
||||
if ($handle === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (($entry = readdir($handle)) !== false) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entryRelative = $relativePath ? $relativePath . '/' . $entry : $entry;
|
||||
$entrySource = $sourcePath . '/' . $entry;
|
||||
$entryTarget = $this->targetDir . '/' . $entryRelative;
|
||||
|
||||
if (is_dir($entrySource)) {
|
||||
// Create target directory if it doesn't exist
|
||||
if (!is_dir($entryTarget)) {
|
||||
mkdir($entryTarget, 0755, true);
|
||||
}
|
||||
|
||||
$this->restoreDirectory($entryRelative, $count);
|
||||
} elseif (is_file($entrySource)) {
|
||||
// Skip excluded files
|
||||
if (in_array($entry, self::EXCLUDE_FILES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip protected files (only at root level)
|
||||
if ($relativePath === '' && in_array($entry, self::SKIP_FILES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
$parentDir = dirname($entryTarget);
|
||||
|
||||
if (!is_dir($parentDir)) {
|
||||
mkdir($parentDir, 0755, true);
|
||||
}
|
||||
|
||||
// Copy file, preserving permissions
|
||||
if (copy($entrySource, $entryTarget)) {
|
||||
// Try to match original permissions
|
||||
$perms = fileperms($entrySource);
|
||||
|
||||
if ($perms !== false) {
|
||||
@chmod($entryTarget, $perms);
|
||||
}
|
||||
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closedir($handle);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<?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\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class FileScanner
|
||||
{
|
||||
private string $rootDir;
|
||||
private array $excludeDirs;
|
||||
private array $excludeFiles;
|
||||
|
||||
/**
|
||||
* @param string $rootDir Root directory to scan
|
||||
* @param array $excludeDirs Relative directory paths to exclude
|
||||
* @param array $excludeFiles Filename patterns to exclude
|
||||
*/
|
||||
public function __construct(string $rootDir, array $excludeDirs = [], array $excludeFiles = [])
|
||||
{
|
||||
$this->rootDir = rtrim($rootDir, '/\\');
|
||||
$this->excludeDirs = array_map(fn($d) => trim($d, '/\\'), $excludeDirs);
|
||||
$this->excludeFiles = $excludeFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the root directory and return relative file paths.
|
||||
*
|
||||
* @return string[] Array of relative file paths
|
||||
*/
|
||||
public function scan(): array
|
||||
{
|
||||
$files = [];
|
||||
$this->scanDirectory('', $files);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function scanDirectory(string $relativePath, array &$files): void
|
||||
{
|
||||
$fullPath = $this->rootDir . ($relativePath ? '/' . $relativePath : '');
|
||||
|
||||
if (!is_dir($fullPath) || !is_readable($fullPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$handle = opendir($fullPath);
|
||||
|
||||
if ($handle === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (($entry = readdir($handle)) !== false) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entryRelative = $relativePath ? $relativePath . '/' . $entry : $entry;
|
||||
$entryFull = $fullPath . '/' . $entry;
|
||||
|
||||
if (is_dir($entryFull)) {
|
||||
if (!$this->isDirExcluded($entryRelative)) {
|
||||
$this->scanDirectory($entryRelative, $files);
|
||||
}
|
||||
} elseif (is_file($entryFull)) {
|
||||
if (!$this->isFileExcluded($entry)) {
|
||||
$files[] = $entryRelative;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closedir($handle);
|
||||
}
|
||||
|
||||
private function isDirExcluded(string $relativePath): bool
|
||||
{
|
||||
$normalized = str_replace('\\', '/', $relativePath);
|
||||
|
||||
foreach ($this->excludeDirs as $excluded) {
|
||||
if ($normalized === $excluded || str_starts_with($normalized, $excluded . '/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Always exclude .git
|
||||
if (basename($relativePath) === '.git') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isFileExcluded(string $filename): bool
|
||||
{
|
||||
foreach ($this->excludeFiles as $pattern) {
|
||||
if ($filename === $pattern || fnmatch($pattern, $filename)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
<?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\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class FtpUploader implements RemoteUploaderInterface
|
||||
{
|
||||
private string $host;
|
||||
private int $port;
|
||||
private string $username;
|
||||
private string $password;
|
||||
private string $remotePath;
|
||||
private bool $passive;
|
||||
private bool $ssl;
|
||||
|
||||
public function __construct(object $profile)
|
||||
{
|
||||
$this->host = $profile->ftp_host ?? '';
|
||||
$this->port = (int) ($profile->ftp_port ?? 21);
|
||||
$this->username = $profile->ftp_username ?? '';
|
||||
$this->password = $profile->ftp_password ?? '';
|
||||
$this->remotePath = rtrim($profile->ftp_path ?? '/backups', '/');
|
||||
$this->passive = (bool) ($profile->ftp_passive ?? true);
|
||||
$this->ssl = (bool) ($profile->ftp_ssl ?? false);
|
||||
}
|
||||
|
||||
public function upload(string $localPath, string $remoteName): array
|
||||
{
|
||||
if (!extension_loaded('ftp')) {
|
||||
return ['success' => false, 'message' => 'PHP ext-ftp is required for FTP uploads. Enable it in php.ini.'];
|
||||
}
|
||||
|
||||
if (empty($this->host)) {
|
||||
return ['success' => false, 'message' => 'FTP host is not configured'];
|
||||
}
|
||||
|
||||
$conn = $this->connect();
|
||||
|
||||
if (!$conn) {
|
||||
return ['success' => false, 'message' => 'Failed to connect to FTP server ' . $this->host . ':' . $this->port];
|
||||
}
|
||||
|
||||
try {
|
||||
if (!@ftp_login($conn, $this->username, $this->password)) {
|
||||
throw new \RuntimeException('FTP login failed for user: ' . $this->username);
|
||||
}
|
||||
|
||||
if ($this->passive) {
|
||||
ftp_pasv($conn, true);
|
||||
}
|
||||
|
||||
// Ensure remote directory exists (create recursively)
|
||||
$this->ensureRemoteDir($conn, $this->remotePath);
|
||||
|
||||
$remoteFile = $this->remotePath . '/' . $remoteName;
|
||||
|
||||
if (!@ftp_put($conn, $remoteFile, $localPath, FTP_BINARY)) {
|
||||
throw new \RuntimeException('Failed to upload file to: ' . $remoteFile);
|
||||
}
|
||||
|
||||
// Verify upload by checking remote file size
|
||||
$remoteSize = @ftp_size($conn, $remoteFile);
|
||||
$localSize = filesize($localPath);
|
||||
|
||||
if ($remoteSize >= 0 && $remoteSize !== $localSize) {
|
||||
throw new \RuntimeException(
|
||||
'Size mismatch: local=' . $localSize . ' remote=' . $remoteSize
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Uploaded to FTP: ' . $remoteFile,
|
||||
'remote_path' => $remoteFile,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => 'FTP upload failed: ' . $e->getMessage()];
|
||||
} finally {
|
||||
@ftp_close($conn);
|
||||
}
|
||||
}
|
||||
|
||||
public function testConnection(): array
|
||||
{
|
||||
if (empty($this->host)) {
|
||||
return ['success' => false, 'message' => 'FTP host is not configured'];
|
||||
}
|
||||
|
||||
$conn = $this->connect();
|
||||
|
||||
if (!$conn) {
|
||||
return ['success' => false, 'message' => 'Cannot connect to ' . $this->host . ':' . $this->port];
|
||||
}
|
||||
|
||||
try {
|
||||
if (!@ftp_login($conn, $this->username, $this->password)) {
|
||||
return ['success' => false, 'message' => 'Login failed for user: ' . $this->username];
|
||||
}
|
||||
|
||||
if ($this->passive) {
|
||||
ftp_pasv($conn, true);
|
||||
}
|
||||
|
||||
$pwd = @ftp_pwd($conn);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Connected to ' . $this->host . ' (cwd: ' . $pwd . ')',
|
||||
];
|
||||
} finally {
|
||||
@ftp_close($conn);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return resource|false
|
||||
*/
|
||||
private function connect()
|
||||
{
|
||||
$timeout = 30;
|
||||
|
||||
if ($this->ssl) {
|
||||
return @ftp_ssl_connect($this->host, $this->port, $timeout);
|
||||
}
|
||||
|
||||
return @ftp_connect($this->host, $this->port, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively create remote directories.
|
||||
*
|
||||
* @param resource $conn FTP connection
|
||||
* @param string $path Remote directory path
|
||||
*/
|
||||
private function ensureRemoteDir($conn, string $path): void
|
||||
{
|
||||
$parts = explode('/', trim($path, '/'));
|
||||
$current = '';
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$current .= '/' . $part;
|
||||
|
||||
if (@ftp_chdir($conn, $current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!@ftp_mkdir($conn, $current)) {
|
||||
throw new \RuntimeException('Cannot create remote directory: ' . $current);
|
||||
}
|
||||
}
|
||||
|
||||
// Return to root
|
||||
@ftp_chdir($conn, '/');
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Google Drive uploader using REST API with OAuth2 refresh tokens.
|
||||
* Uses the resumable upload endpoint for reliable large-file uploads.
|
||||
* No SDK dependency — pure PHP with cURL.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class GoogleDriveUploader implements RemoteUploaderInterface
|
||||
{
|
||||
private const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
private const UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v3/files';
|
||||
private const API_URL = 'https://www.googleapis.com/drive/v3';
|
||||
|
||||
private string $clientId;
|
||||
private string $clientSecret;
|
||||
private string $refreshToken;
|
||||
private string $folderId;
|
||||
|
||||
public function __construct(object $profile)
|
||||
{
|
||||
$this->clientId = $profile->gdrive_client_id ?? '';
|
||||
$this->clientSecret = $profile->gdrive_client_secret ?? '';
|
||||
$this->refreshToken = $profile->gdrive_refresh_token ?? '';
|
||||
$this->folderId = $profile->gdrive_folder_id ?? '';
|
||||
}
|
||||
|
||||
public function upload(string $localPath, string $remoteName): array
|
||||
{
|
||||
if (!extension_loaded('curl')) {
|
||||
return ['success' => false, 'message' => 'PHP ext-curl is required for Google Drive uploads. Enable it in php.ini.'];
|
||||
}
|
||||
|
||||
if (empty($this->clientId) || empty($this->refreshToken)) {
|
||||
return ['success' => false, 'message' => 'Google Drive credentials not configured'];
|
||||
}
|
||||
|
||||
if (!is_file($localPath) || !is_readable($localPath)) {
|
||||
return ['success' => false, 'message' => 'Local file not readable: ' . $localPath];
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Get fresh access token
|
||||
$accessToken = $this->getAccessToken();
|
||||
|
||||
// Step 2: Initiate resumable upload
|
||||
$fileSize = filesize($localPath);
|
||||
$mimeType = 'application/zip';
|
||||
|
||||
$metadata = [
|
||||
'name' => $remoteName,
|
||||
'mimeType' => $mimeType,
|
||||
];
|
||||
|
||||
if (!empty($this->folderId)) {
|
||||
$metadata['parents'] = [$this->folderId];
|
||||
}
|
||||
|
||||
$uploadUri = $this->initiateResumableUpload($accessToken, $metadata, $fileSize, $mimeType);
|
||||
|
||||
// Step 3: Upload file content in chunks
|
||||
$this->uploadFileContent($uploadUri, $localPath, $fileSize, $mimeType);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Uploaded to Google Drive: ' . $remoteName,
|
||||
'remote_path' => 'gdrive://' . ($this->folderId ?: 'root') . '/' . $remoteName,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => 'Google Drive upload failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function testConnection(): array
|
||||
{
|
||||
if (empty($this->clientId) || empty($this->refreshToken)) {
|
||||
return ['success' => false, 'message' => 'Google Drive credentials not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$accessToken = $this->getAccessToken();
|
||||
|
||||
// Test by listing the target folder (or root)
|
||||
$url = self::API_URL . '/about?fields=user';
|
||||
|
||||
$response = $this->curlRequest('GET', $url, null, [
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
]);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
throw new \RuntimeException('API returned HTTP ' . $response['code']);
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
$email = $data['user']['emailAddress'] ?? 'unknown';
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Connected to Google Drive as ' . $email,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the refresh token for a fresh access token.
|
||||
*/
|
||||
private function getAccessToken(): string
|
||||
{
|
||||
$response = $this->curlRequest('POST', self::TOKEN_URL, http_build_query([
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
'refresh_token' => $this->refreshToken,
|
||||
'grant_type' => 'refresh_token',
|
||||
]), [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
]);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
throw new \RuntimeException('Token refresh failed (HTTP ' . $response['code'] . '): ' . $response['body']);
|
||||
}
|
||||
|
||||
$data = json_decode($response['body'], true);
|
||||
|
||||
if (empty($data['access_token'])) {
|
||||
throw new \RuntimeException('No access_token in token response');
|
||||
}
|
||||
|
||||
return $data['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a resumable upload session with Google Drive.
|
||||
*
|
||||
* @return string The resumable upload URI
|
||||
*/
|
||||
private function initiateResumableUpload(string $accessToken, array $metadata, int $fileSize, string $mimeType): string
|
||||
{
|
||||
$url = self::UPLOAD_URL . '?uploadType=resumable';
|
||||
|
||||
$response = $this->curlRequest('POST', $url, json_encode($metadata), [
|
||||
'Authorization: Bearer ' . $accessToken,
|
||||
'Content-Type: application/json; charset=UTF-8',
|
||||
'X-Upload-Content-Type: ' . $mimeType,
|
||||
'X-Upload-Content-Length: ' . $fileSize,
|
||||
], true);
|
||||
|
||||
if ($response['code'] !== 200) {
|
||||
throw new \RuntimeException('Failed to initiate upload (HTTP ' . $response['code'] . '): ' . $response['body']);
|
||||
}
|
||||
|
||||
if (empty($response['headers']['location'])) {
|
||||
throw new \RuntimeException('No upload URI in response headers');
|
||||
}
|
||||
|
||||
return $response['headers']['location'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file content to the resumable upload URI in chunks.
|
||||
*/
|
||||
private function uploadFileContent(string $uploadUri, string $localPath, int $fileSize, string $mimeType): void
|
||||
{
|
||||
$chunkSize = 5 * 1024 * 1024; // 5 MB chunks
|
||||
$handle = fopen($localPath, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Cannot open file for reading: ' . $localPath);
|
||||
}
|
||||
|
||||
try {
|
||||
$offset = 0;
|
||||
|
||||
while ($offset < $fileSize) {
|
||||
$remaining = $fileSize - $offset;
|
||||
$length = min($chunkSize, $remaining);
|
||||
$chunk = fread($handle, $length);
|
||||
|
||||
if ($chunk === false) {
|
||||
throw new \RuntimeException('Failed to read file at offset ' . $offset);
|
||||
}
|
||||
|
||||
$rangeEnd = $offset + $length - 1;
|
||||
|
||||
$response = $this->curlRequest('PUT', $uploadUri, $chunk, [
|
||||
'Content-Type: ' . $mimeType,
|
||||
'Content-Length: ' . $length,
|
||||
'Content-Range: bytes ' . $offset . '-' . $rangeEnd . '/' . $fileSize,
|
||||
]);
|
||||
|
||||
// 308 = Resume Incomplete (more chunks needed), 200/201 = complete
|
||||
if ($response['code'] !== 200 && $response['code'] !== 201 && $response['code'] !== 308) {
|
||||
throw new \RuntimeException(
|
||||
'Chunk upload failed at offset ' . $offset . ' (HTTP ' . $response['code'] . '): ' . $response['body']
|
||||
);
|
||||
}
|
||||
|
||||
$offset += $length;
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a cURL request.
|
||||
*
|
||||
* @param string $method HTTP method
|
||||
* @param string $url Request URL
|
||||
* @param string|null $body Request body
|
||||
* @param array $headers Request headers
|
||||
* @param bool $captureHeaders Whether to capture response headers
|
||||
*
|
||||
* @return array{code: int, body: string, headers?: array}
|
||||
*/
|
||||
private function curlRequest(string $method, string $url, ?string $body, array $headers, bool $captureHeaders = false): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
CURLOPT_CONNECTTIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
]);
|
||||
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$responseHeaders = [];
|
||||
|
||||
if ($captureHeaders) {
|
||||
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $header) use (&$responseHeaders) {
|
||||
$parts = explode(':', $header, 2);
|
||||
|
||||
if (count($parts) === 2) {
|
||||
$responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
|
||||
}
|
||||
|
||||
return strlen($header);
|
||||
});
|
||||
}
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
throw new \RuntimeException('cURL error: ' . $error);
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
$result = ['code' => $httpCode, 'body' => $responseBody];
|
||||
|
||||
if ($captureHeaders) {
|
||||
$result['headers'] = $responseHeaders;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* JPA (Joomla Pack Archive) unarchiver for importing Akeeba Backup files.
|
||||
*
|
||||
* JPA Format Structure:
|
||||
* - Header: signature (3 bytes "JPA"), header length, major/minor version
|
||||
* - Entity headers: signature (3 bytes), header length, path length, path,
|
||||
* compression type (0=none, 1=gzip), compressed size, uncompressed size,
|
||||
* permissions, then compressed data
|
||||
*
|
||||
* Read-only: extracts JPA archives to a staging directory.
|
||||
* The RestoreEngine can then restore from the extracted files.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class JpaUnarchiver
|
||||
{
|
||||
private const JPA_SIGNATURE = "\x4a\x50\x41"; // "JPA"
|
||||
private const ENTITY_SIGNATURE = "\x4a\x50\x46"; // "JPF" — file entity
|
||||
|
||||
private string $archivePath;
|
||||
private string $outputDir;
|
||||
private int $filesExtracted = 0;
|
||||
|
||||
public function __construct(string $archivePath, string $outputDir)
|
||||
{
|
||||
$this->archivePath = $archivePath;
|
||||
$this->outputDir = rtrim($outputDir, '/\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a JPA archive to the output directory.
|
||||
*
|
||||
* @return int Number of files extracted
|
||||
*
|
||||
* @throws \RuntimeException On format errors or extraction failure
|
||||
*/
|
||||
public function extract(): int
|
||||
{
|
||||
if (!is_file($this->archivePath) || !is_readable($this->archivePath)) {
|
||||
throw new \RuntimeException('JPA file not readable: ' . $this->archivePath);
|
||||
}
|
||||
|
||||
$handle = fopen($this->archivePath, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Cannot open JPA file: ' . $this->archivePath);
|
||||
}
|
||||
|
||||
try {
|
||||
// Read and validate archive header
|
||||
$this->readArchiveHeader($handle);
|
||||
|
||||
// Read entities until EOF
|
||||
while (!feof($handle)) {
|
||||
$pos = ftell($handle);
|
||||
|
||||
// Try to read entity signature
|
||||
$sig = fread($handle, 3);
|
||||
|
||||
if ($sig === false || strlen($sig) < 3) {
|
||||
break; // End of archive
|
||||
}
|
||||
|
||||
if ($sig === self::ENTITY_SIGNATURE) {
|
||||
$this->readFileEntity($handle);
|
||||
} else {
|
||||
// Unknown entity — try to skip by reading header length
|
||||
fseek($handle, $pos + 3);
|
||||
$headerLenData = fread($handle, 2);
|
||||
|
||||
if ($headerLenData === false || strlen($headerLenData) < 2) {
|
||||
break;
|
||||
}
|
||||
|
||||
$headerLen = unpack('v', $headerLenData)[1];
|
||||
// Skip remaining header + data
|
||||
fseek($handle, $pos + 3 + $headerLen);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->filesExtracted;
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate the JPA archive header.
|
||||
*/
|
||||
private function readArchiveHeader($handle): void
|
||||
{
|
||||
$signature = fread($handle, 3);
|
||||
|
||||
if ($signature !== self::JPA_SIGNATURE) {
|
||||
throw new \RuntimeException('Not a valid JPA archive — invalid signature');
|
||||
}
|
||||
|
||||
// Header length (2 bytes, little-endian)
|
||||
$headerLenData = fread($handle, 2);
|
||||
|
||||
if ($headerLenData === false || strlen($headerLenData) < 2) {
|
||||
throw new \RuntimeException('Truncated JPA header');
|
||||
}
|
||||
|
||||
$headerLen = unpack('v', $headerLenData)[1];
|
||||
|
||||
// Version: major (1 byte), minor (1 byte)
|
||||
$versionData = fread($handle, 2);
|
||||
|
||||
if ($versionData === false || strlen($versionData) < 2) {
|
||||
throw new \RuntimeException('Cannot read JPA version');
|
||||
}
|
||||
|
||||
// File count (4 bytes, little-endian)
|
||||
$countData = fread($handle, 4);
|
||||
|
||||
if ($countData === false || strlen($countData) < 4) {
|
||||
throw new \RuntimeException('Cannot read file count');
|
||||
}
|
||||
|
||||
// Skip any remaining header bytes
|
||||
$bytesRead = 3 + 2 + 2 + 4; // sig + headerLen + version + count
|
||||
|
||||
if ($headerLen > ($bytesRead - 3)) {
|
||||
$remaining = $headerLen - ($bytesRead - 3);
|
||||
|
||||
if ($remaining > 0) {
|
||||
fseek($handle, ftell($handle) + $remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single file entity and extract it.
|
||||
*/
|
||||
private function readFileEntity($handle): void
|
||||
{
|
||||
// Entity header length (2 bytes)
|
||||
$headerLenData = fread($handle, 2);
|
||||
|
||||
if ($headerLenData === false || strlen($headerLenData) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$headerLen = unpack('v', $headerLenData)[1];
|
||||
|
||||
// Path length (2 bytes)
|
||||
$pathLenData = fread($handle, 2);
|
||||
|
||||
if ($pathLenData === false || strlen($pathLenData) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pathLen = unpack('v', $pathLenData)[1];
|
||||
|
||||
// Path (variable)
|
||||
$path = fread($handle, $pathLen);
|
||||
|
||||
if ($path === false || strlen($path) < $pathLen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compression type (1 byte): 0 = none, 1 = gzip
|
||||
$compTypeData = fread($handle, 1);
|
||||
$compType = ord($compTypeData);
|
||||
|
||||
// Compressed size (4 bytes)
|
||||
$compSizeData = fread($handle, 4);
|
||||
$compSize = unpack('V', $compSizeData)[1];
|
||||
|
||||
// Uncompressed size (4 bytes)
|
||||
$uncompSizeData = fread($handle, 4);
|
||||
$uncompSize = unpack('V', $uncompSizeData)[1];
|
||||
|
||||
// Permissions (4 bytes)
|
||||
$permsData = fread($handle, 4);
|
||||
$perms = unpack('V', $permsData)[1];
|
||||
|
||||
// Skip any remaining header bytes
|
||||
$entityHeaderRead = 2 + 2 + $pathLen + 1 + 4 + 4 + 4;
|
||||
$entityHeaderTotal = $headerLen;
|
||||
|
||||
if ($entityHeaderTotal > $entityHeaderRead) {
|
||||
fseek($handle, ftell($handle) + ($entityHeaderTotal - $entityHeaderRead));
|
||||
}
|
||||
|
||||
// Read compressed data
|
||||
$data = '';
|
||||
|
||||
if ($compSize > 0) {
|
||||
$data = fread($handle, $compSize);
|
||||
|
||||
if ($data === false || strlen($data) < $compSize) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Is this a directory?
|
||||
if (substr($path, -1) === '/' || $uncompSize === 0 && $compSize === 0) {
|
||||
$dirPath = $this->outputDir . '/' . $path;
|
||||
|
||||
if (!is_dir($dirPath)) {
|
||||
mkdir($dirPath, 0755, true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Decompress if needed
|
||||
if ($compType === 1 && !empty($data)) {
|
||||
$data = @gzinflate($data);
|
||||
|
||||
if ($data === false) {
|
||||
throw new \RuntimeException('Failed to decompress file: ' . $path);
|
||||
}
|
||||
}
|
||||
|
||||
// Write file
|
||||
$fullPath = $this->outputDir . '/' . $path;
|
||||
$parentDir = dirname($fullPath);
|
||||
|
||||
if (!is_dir($parentDir)) {
|
||||
mkdir($parentDir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($fullPath, $data);
|
||||
|
||||
// Set permissions (only if reasonable)
|
||||
if ($perms > 0 && $perms <= 0777) {
|
||||
@chmod($fullPath, $perms);
|
||||
}
|
||||
|
||||
$this->filesExtracted++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file appears to be a JPA archive.
|
||||
*/
|
||||
public static function isJpaFile(string $path): bool
|
||||
{
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$handle = fopen($path, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sig = fread($handle, 3);
|
||||
fclose($handle);
|
||||
|
||||
return $sig === self::JPA_SIGNATURE;
|
||||
}
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Standalone restore script generator.
|
||||
*
|
||||
* When "Include MokoRestore" is enabled on a profile, the backup archive
|
||||
* is wrapped:
|
||||
*
|
||||
* outer.zip
|
||||
* ├── restore.php ← Standalone restore script (no Joomla needed)
|
||||
* └── site-backup.zip ← The actual site backup
|
||||
*
|
||||
* Upload outer.zip to a blank server, extract, open restore.php in a
|
||||
* browser, and it handles everything — self-contained site restoration.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class MokoRestore
|
||||
{
|
||||
/**
|
||||
* Wrap a backup archive with the standalone restore script.
|
||||
*
|
||||
* @param string $backupArchive Path to the original backup ZIP
|
||||
* @param string $outputPath Path for the wrapped archive
|
||||
*
|
||||
* @return string Path to the wrapped archive
|
||||
*/
|
||||
public static function wrap(string $backupArchive, string $outputPath): string
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath);
|
||||
}
|
||||
|
||||
// Add the standalone restore script
|
||||
$zip->addFromString('restore.php', self::generateRestoreScript());
|
||||
|
||||
// Add the original backup as a nested ZIP
|
||||
$zip->addFile($backupArchive, 'site-backup.zip');
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone restore.php script.
|
||||
*
|
||||
* This is a self-contained PHP file that:
|
||||
* 1. Provides a web UI for configuration (DB credentials, etc.)
|
||||
* 2. Extracts site-backup.zip to the current directory
|
||||
* 3. Imports database.sql using the provided credentials
|
||||
* 4. Updates configuration.php with new settings
|
||||
* 5. Cleans up after itself
|
||||
*/
|
||||
private static function generateRestoreScript(): string
|
||||
{
|
||||
return <<<'RESTORE_PHP'
|
||||
<?php
|
||||
/**
|
||||
* MokoRestore — Standalone Site Restoration Tool
|
||||
*
|
||||
* Upload this file alongside site-backup.zip to your server.
|
||||
* Open restore.php in your browser and follow the steps.
|
||||
*
|
||||
* DELETE THIS FILE AFTER RESTORATION IS COMPLETE.
|
||||
*
|
||||
* @package MokoJoomBackup
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
set_time_limit(0);
|
||||
|
||||
define('MOKOBACKUP_RESTORE', 1);
|
||||
define('RESTORE_DIR', __DIR__);
|
||||
define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');
|
||||
|
||||
// ── Security: simple token to prevent CSRF ─────────────────────────
|
||||
session_start();
|
||||
|
||||
if (empty($_SESSION['restore_token'])) {
|
||||
$_SESSION['restore_token'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
$token = $_SESSION['restore_token'];
|
||||
|
||||
// ── Handle AJAX actions ────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if (!isset($_POST['token']) || $_POST['token'] !== $token) {
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid token']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$action = $_POST['action'];
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'preflight':
|
||||
// Override PHP limits for restore
|
||||
@set_time_limit(0);
|
||||
@ini_set('max_execution_time', '0');
|
||||
@ini_set('memory_limit', '512M');
|
||||
@ignore_user_abort(true);
|
||||
|
||||
$checks = [];
|
||||
$checks['php_version'] = ['value' => PHP_VERSION, 'ok' => version_compare(PHP_VERSION, '8.1', '>=')];
|
||||
$checks['zip_ext'] = ['value' => extension_loaded('zip') ? 'Yes' : 'No', 'ok' => extension_loaded('zip')];
|
||||
$checks['pdo_mysql'] = ['value' => extension_loaded('pdo_mysql') ? 'Yes' : 'No', 'ok' => extension_loaded('pdo_mysql')];
|
||||
$checks['mbstring'] = ['value' => extension_loaded('mbstring') ? 'Yes' : 'No', 'ok' => extension_loaded('mbstring')];
|
||||
$checks['backup_exists'] = ['value' => file_exists(BACKUP_FILE) ? 'Yes' : 'No', 'ok' => file_exists(BACKUP_FILE)];
|
||||
$checks['writable'] = ['value' => is_writable(RESTORE_DIR) ? 'Yes' : 'No', 'ok' => is_writable(RESTORE_DIR)];
|
||||
$checks['max_execution_time'] = ['value' => ini_get('max_execution_time') ?: 'unlimited', 'ok' => true];
|
||||
$checks['memory_limit'] = ['value' => ini_get('memory_limit'), 'ok' => true];
|
||||
|
||||
if (file_exists(BACKUP_FILE)) {
|
||||
$checks['backup_size'] = ['value' => number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB', 'ok' => true];
|
||||
}
|
||||
|
||||
$allOk = true;
|
||||
foreach ($checks as $c) {
|
||||
if (!$c['ok']) $allOk = false;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => $allOk, 'checks' => $checks]);
|
||||
break;
|
||||
|
||||
case 'extract':
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open(BACKUP_FILE) !== true) {
|
||||
throw new RuntimeException('Cannot open backup archive');
|
||||
}
|
||||
|
||||
// Set decryption password if provided
|
||||
$archivePassword = $_POST['archive_password'] ?? '';
|
||||
if (!empty($archivePassword)) {
|
||||
$zip->setPassword($archivePassword);
|
||||
}
|
||||
|
||||
if (!$zip->extractTo(RESTORE_DIR)) {
|
||||
$zip->close();
|
||||
throw new RuntimeException(
|
||||
'Extraction failed. ' . (!empty($archivePassword) ? 'Check the decryption password.' : 'The archive may be encrypted.')
|
||||
);
|
||||
}
|
||||
|
||||
$count = $zip->numFiles;
|
||||
$zip->close();
|
||||
|
||||
echo json_encode(['success' => true, 'message' => "Extracted {$count} files"]);
|
||||
break;
|
||||
|
||||
case 'database':
|
||||
$host = $_POST['db_host'] ?? 'localhost';
|
||||
$name = $_POST['db_name'] ?? '';
|
||||
$user = $_POST['db_user'] ?? '';
|
||||
$pass = $_POST['db_pass'] ?? '';
|
||||
$prefix = $_POST['db_prefix'] ?? 'moko_';
|
||||
|
||||
if (empty($name) || empty($user)) {
|
||||
throw new RuntimeException('Database name and user are required');
|
||||
}
|
||||
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$host};dbname={$name};charset=utf8mb4",
|
||||
$user,
|
||||
$pass,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$sqlFile = RESTORE_DIR . '/database.sql';
|
||||
|
||||
if (!file_exists($sqlFile)) {
|
||||
echo json_encode(['success' => true, 'message' => 'No database.sql found — skipped']);
|
||||
break;
|
||||
}
|
||||
|
||||
$sql = file_get_contents($sqlFile);
|
||||
$statements = 0;
|
||||
$errors = 0;
|
||||
|
||||
// Split on semicolons (simple splitter for our dump format)
|
||||
$parts = explode(";\n", $sql);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
|
||||
if (empty($part) || strpos($part, '--') === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->exec($part);
|
||||
$statements++;
|
||||
} catch (PDOException $e) {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
$host = $_POST['db_host'] ?? 'localhost';
|
||||
$name = $_POST['db_name'] ?? '';
|
||||
$user = $_POST['db_user'] ?? '';
|
||||
$pass = $_POST['db_pass'] ?? '';
|
||||
$prefix = $_POST['db_prefix'] ?? 'moko_';
|
||||
$sitename = $_POST['sitename'] ?? 'Restored Site';
|
||||
$livesite = $_POST['live_site'] ?? '';
|
||||
$tmpPath = $_POST['tmp_path'] ?? RESTORE_DIR . '/tmp';
|
||||
$logPath = $_POST['log_path'] ?? RESTORE_DIR . '/administrator/logs';
|
||||
|
||||
$configFile = RESTORE_DIR . '/configuration.php';
|
||||
|
||||
if (file_exists($configFile)) {
|
||||
// Update existing configuration.php
|
||||
$config = file_get_contents($configFile);
|
||||
$replacements = [
|
||||
'/\$host\s*=\s*\'[^\']*\'/' => "\$host = '{$host}'",
|
||||
'/\$db\s*=\s*\'[^\']*\'/' => "\$db = '{$name}'",
|
||||
'/\$user\s*=\s*\'[^\']*\'/' => "\$user = '{$user}'",
|
||||
'/\$password\s*=\s*\'[^\']*\'/' => "\$password = '{$pass}'",
|
||||
'/\$dbprefix\s*=\s*\'[^\']*\'/' => "\$dbprefix = '{$prefix}'",
|
||||
'/\$tmp_path\s*=\s*\'[^\']*\'/' => "\$tmp_path = '{$tmpPath}'",
|
||||
'/\$log_path\s*=\s*\'[^\']*\'/' => "\$log_path = '{$logPath}'",
|
||||
];
|
||||
|
||||
if (!empty($livesite)) {
|
||||
$replacements['/\$live_site\s*=\s*\'[^\']*\'/'] = "\$live_site = '{$livesite}'";
|
||||
}
|
||||
|
||||
foreach ($replacements as $pattern => $replacement) {
|
||||
$config = preg_replace($pattern, $replacement, $config);
|
||||
}
|
||||
|
||||
file_put_contents($configFile, $config);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Configuration updated']);
|
||||
break;
|
||||
|
||||
case 'cleanup':
|
||||
// Remove restore artifacts
|
||||
@unlink(RESTORE_DIR . '/database.sql');
|
||||
@unlink(BACKUP_FILE);
|
||||
// Don't delete restore.php here — user does it manually
|
||||
|
||||
echo json_encode(['success' => true, 'message' => 'Cleanup complete. DELETE restore.php manually!']);
|
||||
break;
|
||||
|
||||
default:
|
||||
echo json_encode(['success' => false, 'message' => 'Unknown action']);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── HTML UI ────────────────────────────────────────────────────────
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MokoJoomBackup — Site Restore</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 2rem; }
|
||||
.container { max-width: 700px; margin: 0 auto; }
|
||||
h1 { color: #00d4ff; margin-bottom: 0.5rem; }
|
||||
.subtitle { color: #888; margin-bottom: 2rem; }
|
||||
.card { background: #16213e; border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; border: 1px solid #0f3460; }
|
||||
.card h2 { color: #00d4ff; font-size: 1.1rem; margin-bottom: 1rem; }
|
||||
label { display: block; margin-bottom: 0.3rem; color: #aaa; font-size: 0.9rem; }
|
||||
input[type="text"], input[type="password"] { width: 100%; padding: 0.5rem; border: 1px solid #0f3460; border-radius: 4px; background: #1a1a2e; color: #e0e0e0; margin-bottom: 0.8rem; }
|
||||
.btn { display: inline-block; padding: 0.6rem 1.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; margin-right: 0.5rem; margin-top: 0.5rem; }
|
||||
.btn-primary { background: #00d4ff; color: #1a1a2e; font-weight: bold; }
|
||||
.btn-danger { background: #e94560; color: white; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.log { background: #0d1117; border: 1px solid #0f3460; border-radius: 4px; padding: 1rem; font-family: monospace; font-size: 0.85rem; white-space: pre-wrap; max-height: 300px; overflow-y: auto; margin-top: 1rem; }
|
||||
.check-ok { color: #4caf50; }
|
||||
.check-fail { color: #e94560; }
|
||||
.warning { background: #e94560; color: white; padding: 1rem; border-radius: 4px; margin-bottom: 1rem; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>MokoJoomBackup Restore</h1>
|
||||
<p class="subtitle">Standalone site restoration tool</p>
|
||||
|
||||
<div class="warning">DELETE this file (restore.php) immediately after restoration is complete!</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Step 1: Pre-flight Checks</h2>
|
||||
<div id="checks"></div>
|
||||
<button class="btn btn-primary" onclick="runPreflight()">Run Checks</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Step 2: Extract Files</h2>
|
||||
<label>Archive Password (leave blank if not encrypted)</label>
|
||||
<input type="password" id="archive_password" value="" style="margin-bottom:0.5rem;">
|
||||
<button class="btn btn-primary" onclick="runExtract()" id="btnExtract" disabled>Extract site-backup.zip</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Step 3: Database Restore</h2>
|
||||
<label>DB Host</label><input type="text" id="db_host" value="localhost">
|
||||
<label>DB Name</label><input type="text" id="db_name" value="">
|
||||
<label>DB User</label><input type="text" id="db_user" value="">
|
||||
<label>DB Password</label><input type="password" id="db_pass" value="">
|
||||
<label>Table Prefix</label><input type="text" id="db_prefix" value="moko_">
|
||||
<button class="btn btn-primary" onclick="runDatabase()" id="btnDb" disabled>Import Database</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Step 4: Update Configuration</h2>
|
||||
<label>Site Name</label><input type="text" id="sitename" value="Restored Site">
|
||||
<label>Live Site URL (optional)</label><input type="text" id="live_site" value="" placeholder="https://example.com">
|
||||
<button class="btn btn-primary" onclick="runConfig()" id="btnConfig" disabled>Update configuration.php</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Step 5: Cleanup</h2>
|
||||
<button class="btn btn-danger" onclick="runCleanup()" id="btnCleanup" disabled>Remove Restore Artifacts</button>
|
||||
</div>
|
||||
|
||||
<div class="log" id="log">Ready. Click "Run Checks" to begin.</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TOKEN = <?php echo json_encode($token); ?>;
|
||||
|
||||
function log(msg) {
|
||||
const el = document.getElementById('log');
|
||||
el.textContent += '\n[' + new Date().toLocaleTimeString() + '] ' + msg;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
async function post(action, extra = {}) {
|
||||
const form = new URLSearchParams();
|
||||
form.append('action', action);
|
||||
form.append('token', TOKEN);
|
||||
for (const [k, v] of Object.entries(extra)) form.append(k, v);
|
||||
const res = await fetch('restore.php', { method: 'POST', body: form });
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function runPreflight() {
|
||||
log('Running pre-flight checks...');
|
||||
const r = await post('preflight');
|
||||
const el = document.getElementById('checks');
|
||||
el.innerHTML = '';
|
||||
for (const [name, check] of Object.entries(r.checks)) {
|
||||
const div = document.createElement('div');
|
||||
div.className = check.ok ? 'check-ok' : 'check-fail';
|
||||
div.textContent = (check.ok ? '✓ ' : '✗ ') + name + ': ' + check.value;
|
||||
el.appendChild(div);
|
||||
}
|
||||
if (r.success) {
|
||||
document.getElementById('btnExtract').disabled = false;
|
||||
log('All checks passed');
|
||||
} else {
|
||||
log('Some checks failed — fix issues before proceeding');
|
||||
}
|
||||
}
|
||||
|
||||
async function runExtract() {
|
||||
log('Extracting site-backup.zip...');
|
||||
const pw = document.getElementById('archive_password').value;
|
||||
const r = await post('extract', pw ? { archive_password: pw } : {});
|
||||
log(r.success ? r.message : 'FAILED: ' + r.message);
|
||||
if (r.success) document.getElementById('btnDb').disabled = false;
|
||||
}
|
||||
|
||||
async function runDatabase() {
|
||||
log('Importing database...');
|
||||
const r = await post('database', {
|
||||
db_host: document.getElementById('db_host').value,
|
||||
db_name: document.getElementById('db_name').value,
|
||||
db_user: document.getElementById('db_user').value,
|
||||
db_pass: document.getElementById('db_pass').value,
|
||||
db_prefix: document.getElementById('db_prefix').value,
|
||||
});
|
||||
log(r.success ? r.message : 'FAILED: ' + r.message);
|
||||
if (r.success) document.getElementById('btnConfig').disabled = false;
|
||||
}
|
||||
|
||||
async function runConfig() {
|
||||
log('Updating configuration.php...');
|
||||
const r = await post('config', {
|
||||
db_host: document.getElementById('db_host').value,
|
||||
db_name: document.getElementById('db_name').value,
|
||||
db_user: document.getElementById('db_user').value,
|
||||
db_pass: document.getElementById('db_pass').value,
|
||||
db_prefix: document.getElementById('db_prefix').value,
|
||||
sitename: document.getElementById('sitename').value,
|
||||
live_site: document.getElementById('live_site').value,
|
||||
tmp_path: '<?php echo addslashes(RESTORE_DIR); ?>/tmp',
|
||||
log_path: '<?php echo addslashes(RESTORE_DIR); ?>/administrator/logs',
|
||||
});
|
||||
log(r.success ? r.message : 'FAILED: ' + r.message);
|
||||
if (r.success) document.getElementById('btnCleanup').disabled = false;
|
||||
}
|
||||
|
||||
async function runCleanup() {
|
||||
log('Cleaning up...');
|
||||
const r = await post('cleanup');
|
||||
log(r.success ? r.message : 'FAILED: ' + r.message);
|
||||
log('\n=== RESTORE COMPLETE ===');
|
||||
log('IMPORTANT: Delete restore.php from your server NOW!');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
RESTORE_PHP;
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Sends email notifications on backup success or failure.
|
||||
* Uses Joomla's built-in mail system (Factory::getMailer()).
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
class NotificationSender
|
||||
{
|
||||
/**
|
||||
* Send a backup notification email.
|
||||
*
|
||||
* @param object $profile Profile object with notification settings
|
||||
* @param object $record Backup record object with results
|
||||
* @param bool $success Whether the backup succeeded
|
||||
* @param string $logText Backup log text
|
||||
*
|
||||
* @return bool True if email was sent
|
||||
*/
|
||||
public static function send(object $profile, object $record, bool $success, string $logText = ''): bool
|
||||
{
|
||||
$notifyEmail = trim($profile->notify_email ?? '');
|
||||
$notifyUserGroups = $profile->notify_user_groups ?? '';
|
||||
|
||||
// Resolve user group members to email addresses
|
||||
$groupEmails = self::resolveUserGroupEmails($notifyUserGroups);
|
||||
|
||||
if (empty($notifyEmail) && empty($groupEmails)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check notification preferences
|
||||
if ($success && empty($profile->notify_on_success)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$success && empty($profile->notify_on_failure)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$mailer = Factory::getMailer();
|
||||
$config = Factory::getApplication()->getConfig();
|
||||
$siteName = $config->get('sitename', 'Joomla Site');
|
||||
$siteUrl = Uri::root();
|
||||
|
||||
// Parse recipient list (comma-separated) + user group emails
|
||||
$recipients = array_map('trim', explode(',', $notifyEmail));
|
||||
$recipients = array_merge($recipients, $groupEmails);
|
||||
$recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL)));
|
||||
|
||||
if (empty($recipients)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($recipients as $recipient) {
|
||||
$mailer->addRecipient($recipient);
|
||||
}
|
||||
|
||||
// Build subject
|
||||
$statusLabel = $success ? 'SUCCESS' : 'FAILED';
|
||||
$mailer->setSubject("[MokoBackup] {$statusLabel}: {$record->description} — {$siteName}");
|
||||
|
||||
// Build body
|
||||
$duration = '';
|
||||
|
||||
if (!empty($record->backupstart) && !empty($record->backupend)
|
||||
&& $record->backupend !== '0000-00-00 00:00:00') {
|
||||
$start = strtotime($record->backupstart);
|
||||
$end = strtotime($record->backupend);
|
||||
$seconds = max(0, $end - $start);
|
||||
$duration = $seconds < 60
|
||||
? $seconds . ' seconds'
|
||||
: round($seconds / 60, 1) . ' minutes';
|
||||
}
|
||||
|
||||
$sizeHuman = $record->total_size > 0
|
||||
? number_format($record->total_size / 1048576, 2) . ' MB'
|
||||
: 'N/A';
|
||||
|
||||
$body = "MokoJoomBackup Notification\n"
|
||||
. "============================\n\n"
|
||||
. "Site: {$siteName}\n"
|
||||
. "URL: {$siteUrl}\n"
|
||||
. "Status: {$statusLabel}\n"
|
||||
. "Profile: {$profile->title}\n"
|
||||
. "Description: {$record->description}\n"
|
||||
. "Type: {$record->backup_type}\n"
|
||||
. "Origin: {$record->origin}\n"
|
||||
. "Archive: {$record->archivename}\n"
|
||||
. "Size: {$sizeHuman}\n"
|
||||
. "Duration: {$duration}\n"
|
||||
. "Started: {$record->backupstart}\n"
|
||||
. "Ended: {$record->backupend}\n";
|
||||
|
||||
if (!empty($record->remote_filename)) {
|
||||
$body .= "Remote: {$record->remote_filename}\n";
|
||||
}
|
||||
|
||||
if ($record->files_count > 0 || $record->tables_count > 0) {
|
||||
$body .= "Files: {$record->files_count}\n"
|
||||
. "Tables: {$record->tables_count}\n";
|
||||
}
|
||||
|
||||
// Add log excerpt on failure (last 30 lines)
|
||||
if (!$success && !empty($logText)) {
|
||||
$logLines = explode("\n", $logText);
|
||||
$excerpt = array_slice($logLines, -30);
|
||||
$body .= "\n--- Log (last 30 lines) ---\n"
|
||||
. implode("\n", $excerpt) . "\n";
|
||||
}
|
||||
|
||||
$body .= "\n--\n"
|
||||
. "MokoJoomBackup — https://mokoconsulting.tech\n";
|
||||
|
||||
$mailer->setBody($body);
|
||||
$mailer->isHtml(false);
|
||||
|
||||
return $mailer->Send();
|
||||
} catch (\Throwable $e) {
|
||||
// Don't let notification failure break the backup flow
|
||||
error_log('MokoBackup notification error: ' . $e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve user group IDs to email addresses of group members.
|
||||
*
|
||||
* @param string|array $groups Comma-separated group IDs or array
|
||||
*
|
||||
* @return array Email addresses
|
||||
*/
|
||||
private static function resolveUserGroupEmails(string|array $groups): array
|
||||
{
|
||||
if (empty($groups)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (\is_string($groups)) {
|
||||
$groups = array_filter(array_map('intval', explode(',', $groups)));
|
||||
}
|
||||
|
||||
if (empty($groups)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('DISTINCT ' . $db->quoteName('u.email'))
|
||||
->from($db->quoteName('#__users', 'u'))
|
||||
->join('INNER', $db->quoteName('#__user_usergroup_map', 'ugm') . ' ON ugm.user_id = u.id')
|
||||
->where($db->quoteName('u.block') . ' = 0')
|
||||
->whereIn($db->quoteName('ugm.group_id'), $groups);
|
||||
$db->setQuery($query);
|
||||
|
||||
return $db->loadColumn() ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Resolves placeholders like [host], [date], [profile_name] in backup
|
||||
* directory paths and archive filename formats.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class PlaceholderResolver
|
||||
{
|
||||
/**
|
||||
* Supported placeholders and their descriptions (for documentation).
|
||||
*/
|
||||
public const PLACEHOLDERS = [
|
||||
'[host]' => 'Server hostname',
|
||||
'[date]' => 'Date as Ymd (e.g. 20260604)',
|
||||
'[time]' => 'Time as His (e.g. 143025)',
|
||||
'[datetime]' => 'Date and time as Ymd_His',
|
||||
'[year]' => 'Four-digit year',
|
||||
'[month]' => 'Two-digit month',
|
||||
'[day]' => 'Two-digit day',
|
||||
'[hour]' => 'Two-digit hour (24h)',
|
||||
'[minute]' => 'Two-digit minute',
|
||||
'[second]' => 'Two-digit second',
|
||||
'[profile_id]' => 'Backup profile ID',
|
||||
'[profile_name]' => 'Profile title (sanitized)',
|
||||
'[site_name]' => 'Joomla site name (sanitized)',
|
||||
'[type]' => 'Backup type (full, database, files, differential)',
|
||||
'[random]' => 'Random 6-character hex string',
|
||||
];
|
||||
|
||||
private array $replacements;
|
||||
|
||||
/**
|
||||
* @param object $profile The backup profile object
|
||||
*/
|
||||
public function __construct(object $profile)
|
||||
{
|
||||
$now = new \DateTimeImmutable('now');
|
||||
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
|
||||
|
||||
$siteName = '';
|
||||
|
||||
try {
|
||||
$siteName = Factory::getApplication()->get('sitename', '');
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback: not critical
|
||||
}
|
||||
|
||||
$this->replacements = [
|
||||
'[host]' => $hostname,
|
||||
'[date]' => $now->format('Ymd'),
|
||||
'[time]' => $now->format('His'),
|
||||
'[datetime]' => $now->format('Ymd_His'),
|
||||
'[year]' => $now->format('Y'),
|
||||
'[month]' => $now->format('m'),
|
||||
'[day]' => $now->format('d'),
|
||||
'[hour]' => $now->format('H'),
|
||||
'[minute]' => $now->format('i'),
|
||||
'[second]' => $now->format('s'),
|
||||
'[profile_id]' => (string) ($profile->id ?? '0'),
|
||||
'[profile_name]' => $this->sanitize($profile->title ?? 'default'),
|
||||
'[site_name]' => $this->sanitize($siteName ?: 'joomla'),
|
||||
'[type]' => $profile->backup_type ?? 'full',
|
||||
'[random]' => bin2hex(random_bytes(3)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all placeholders in a string.
|
||||
*
|
||||
* @param string $template String containing [placeholder] tokens
|
||||
*
|
||||
* @return string Resolved string
|
||||
*/
|
||||
public function resolve(string $template): string
|
||||
{
|
||||
return str_replace(
|
||||
array_keys($this->replacements),
|
||||
array_values($this->replacements),
|
||||
$template
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw hostname value (for backward compatibility).
|
||||
*/
|
||||
public function getHostname(): string
|
||||
{
|
||||
return $this->replacements['[host]'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the datetime tag value (for backward compatibility).
|
||||
*/
|
||||
public function getTag(): string
|
||||
{
|
||||
return $this->replacements['[datetime]'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a string for use in filenames/paths.
|
||||
* Keeps alphanumerics, dots, hyphens, underscores. Replaces spaces with hyphens.
|
||||
*/
|
||||
private function sanitize(string $value): string
|
||||
{
|
||||
$value = str_replace(' ', '-', trim($value));
|
||||
|
||||
return preg_replace('/[^a-zA-Z0-9._-]/', '', $value);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?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\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
interface RemoteUploaderInterface
|
||||
{
|
||||
/**
|
||||
* Upload a file to remote storage.
|
||||
*
|
||||
* @param string $localPath Absolute path to the local file
|
||||
* @param string $remoteName Filename to use on the remote end
|
||||
*
|
||||
* @return array{success: bool, message: string, remote_path?: string}
|
||||
*/
|
||||
public function upload(string $localPath, string $remoteName): array;
|
||||
|
||||
/**
|
||||
* Test the connection / credentials without uploading.
|
||||
*
|
||||
* @return array{success: bool, message: string}
|
||||
*/
|
||||
public function testConnection(): array;
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Restore engine — extracts a backup archive and reimports the database.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Extract ZIP to a temp staging directory
|
||||
* 2. Preserve current configuration.php (DB credentials, paths)
|
||||
* 3. Restore files from staging to Joomla root
|
||||
* 4. Import database.sql (if present in archive)
|
||||
* 5. Restore preserved configuration.php
|
||||
* 6. Clean up staging directory
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class RestoreEngine
|
||||
{
|
||||
private array $log = [];
|
||||
private string $stagingDir;
|
||||
|
||||
/**
|
||||
* Run a full restore from a backup record.
|
||||
*
|
||||
* @param int $recordId Backup record ID to restore from
|
||||
* @param bool $restoreFiles Whether to restore files
|
||||
* @param bool $restoreDb Whether to restore the database
|
||||
* @param bool $preserveConfig Keep current configuration.php
|
||||
* @param string $password Decryption password (for encrypted archives)
|
||||
*
|
||||
* @return array{success: bool, message: string}
|
||||
*/
|
||||
public function restore(int $recordId, bool $restoreFiles = true, bool $restoreDb = true, bool $preserveConfig = true, string $password = ''): array
|
||||
{
|
||||
// Override PHP limits — restores can take a long time
|
||||
@set_time_limit(0);
|
||||
@ini_set('max_execution_time', '0');
|
||||
@ini_set('memory_limit', '512M');
|
||||
@ignore_user_abort(true);
|
||||
|
||||
if (!extension_loaded('zip')) {
|
||||
return ['success' => false, 'message' => 'PHP ext-zip is required for restore operations'];
|
||||
}
|
||||
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load backup record
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokobackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . $recordId);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
|
||||
if (!$record) {
|
||||
return ['success' => false, 'message' => 'Backup record not found: ' . $recordId];
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete') {
|
||||
return ['success' => false, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')'];
|
||||
}
|
||||
|
||||
$archivePath = $record->absolute_path;
|
||||
|
||||
if (!is_file($archivePath) || !is_readable($archivePath)) {
|
||||
return ['success' => false, 'message' => 'Backup archive not found: ' . $archivePath];
|
||||
}
|
||||
|
||||
// Create staging directory
|
||||
$this->stagingDir = JPATH_ROOT . '/tmp/mokobackup-restore-' . $record->tag;
|
||||
|
||||
if (is_dir($this->stagingDir)) {
|
||||
$this->recursiveDelete($this->stagingDir);
|
||||
}
|
||||
|
||||
mkdir($this->stagingDir, 0755, true);
|
||||
|
||||
try {
|
||||
// Step 1: Extract archive to staging
|
||||
$this->log('Extracting archive: ' . basename($archivePath));
|
||||
|
||||
// Detect format: JPA, tar.gz, or ZIP
|
||||
if (JpaUnarchiver::isJpaFile($archivePath)) {
|
||||
$this->log('Detected JPA format (Akeeba Backup archive)');
|
||||
$jpa = new JpaUnarchiver($archivePath, $this->stagingDir);
|
||||
$count = $jpa->extract();
|
||||
$this->log('Extracted ' . $count . ' files from JPA');
|
||||
} elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) {
|
||||
$this->log('Detected tar.gz format');
|
||||
$this->extractTarGz($archivePath);
|
||||
} else {
|
||||
$this->extractArchive($archivePath, $password);
|
||||
}
|
||||
$this->log('Extraction complete');
|
||||
|
||||
// Step 2: Preserve configuration.php
|
||||
$configBackup = '';
|
||||
|
||||
if ($preserveConfig && is_file(JPATH_ROOT . '/configuration.php')) {
|
||||
$configBackup = file_get_contents(JPATH_ROOT . '/configuration.php');
|
||||
$this->log('Current configuration.php preserved');
|
||||
}
|
||||
|
||||
// Step 3: Restore files
|
||||
if ($restoreFiles) {
|
||||
$this->log('Restoring files...');
|
||||
$restorer = new FileRestorer($this->stagingDir, JPATH_ROOT);
|
||||
$fileCount = $restorer->restore();
|
||||
$this->log('Files restored: ' . $fileCount);
|
||||
}
|
||||
|
||||
// Step 4: Import database
|
||||
if ($restoreDb) {
|
||||
$sqlFile = $this->stagingDir . '/database.sql';
|
||||
|
||||
if (is_file($sqlFile)) {
|
||||
$this->log('Importing database...');
|
||||
$importer = new DatabaseImporter();
|
||||
$tableCount = $importer->import($sqlFile);
|
||||
$this->log('Database imported: ' . $tableCount . ' statements executed');
|
||||
} else {
|
||||
$this->log('No database.sql found in archive — skipping database restore');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Restore preserved configuration.php
|
||||
if ($preserveConfig && !empty($configBackup)) {
|
||||
file_put_contents(JPATH_ROOT . '/configuration.php', $configBackup);
|
||||
$this->log('Configuration.php restored to pre-restore state');
|
||||
}
|
||||
|
||||
// Step 6: Clean up staging
|
||||
$this->recursiveDelete($this->stagingDir);
|
||||
$this->log('Staging directory cleaned up');
|
||||
|
||||
$this->log('Restore complete');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Restore complete from: ' . basename($archivePath),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Restore config even on failure
|
||||
if ($preserveConfig && !empty($configBackup)) {
|
||||
file_put_contents(JPATH_ROOT . '/configuration.php', $configBackup);
|
||||
$this->log('Configuration.php restored after failure');
|
||||
}
|
||||
|
||||
// Clean up staging on failure
|
||||
if (is_dir($this->stagingDir)) {
|
||||
$this->recursiveDelete($this->stagingDir);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||
'log' => implode("\n", $this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a ZIP archive to the staging directory.
|
||||
*/
|
||||
private function extractArchive(string $archivePath, string $password = ''): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
$result = $zip->open($archivePath);
|
||||
|
||||
if ($result !== true) {
|
||||
throw new \RuntimeException('Cannot open archive (error code: ' . $result . ')');
|
||||
}
|
||||
|
||||
// Set decryption password if provided
|
||||
if (!empty($password)) {
|
||||
$zip->setPassword($password);
|
||||
$this->log('Decryption password set');
|
||||
}
|
||||
|
||||
if (!$zip->extractTo($this->stagingDir)) {
|
||||
$zip->close();
|
||||
|
||||
throw new \RuntimeException(
|
||||
'Failed to extract archive. '
|
||||
. (!empty($password) ? 'Check that the decryption password is correct.' : 'The archive may be encrypted — provide a password.')
|
||||
);
|
||||
}
|
||||
|
||||
$this->log('Extracted ' . $zip->numFiles . ' entries');
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a tar.gz archive to the staging directory.
|
||||
*/
|
||||
private function extractTarGz(string $archivePath): void
|
||||
{
|
||||
$phar = new \PharData($archivePath);
|
||||
$phar->extractTo($this->stagingDir, null, true);
|
||||
$this->log('Extracted tar.gz archive');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete a directory and all its contents.
|
||||
*/
|
||||
private function recursiveDelete(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($item->isDir()) {
|
||||
@rmdir($item->getPathname());
|
||||
} else {
|
||||
@unlink($item->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
}
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* S3-compatible storage uploader using AWS Signature V4.
|
||||
* Works with AWS S3, Wasabi, Backblaze B2, MinIO, and any S3-compatible API.
|
||||
* No SDK dependency — pure PHP with cURL.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class S3Uploader implements RemoteUploaderInterface
|
||||
{
|
||||
private string $endpoint;
|
||||
private string $region;
|
||||
private string $accessKey;
|
||||
private string $secretKey;
|
||||
private string $bucket;
|
||||
private string $path;
|
||||
|
||||
private const DEFAULT_ENDPOINT = 'https://s3.amazonaws.com';
|
||||
private const SERVICE = 's3';
|
||||
|
||||
public function __construct(object $profile)
|
||||
{
|
||||
$this->endpoint = rtrim($profile->s3_endpoint ?? '', '/') ?: self::DEFAULT_ENDPOINT;
|
||||
$this->region = $profile->s3_region ?? 'us-east-1';
|
||||
$this->accessKey = $profile->s3_access_key ?? '';
|
||||
$this->secretKey = $profile->s3_secret_key ?? '';
|
||||
$this->bucket = $profile->s3_bucket ?? '';
|
||||
$this->path = trim($profile->s3_path ?? '/backups', '/');
|
||||
}
|
||||
|
||||
public function upload(string $localPath, string $remoteName): array
|
||||
{
|
||||
if (!extension_loaded('curl')) {
|
||||
return ['success' => false, 'message' => 'PHP ext-curl is required for S3 uploads. Enable it in php.ini.'];
|
||||
}
|
||||
|
||||
if (empty($this->accessKey) || empty($this->secretKey) || empty($this->bucket)) {
|
||||
return ['success' => false, 'message' => 'S3 credentials or bucket not configured'];
|
||||
}
|
||||
|
||||
if (!is_file($localPath) || !is_readable($localPath)) {
|
||||
return ['success' => false, 'message' => 'Local file not readable: ' . $localPath];
|
||||
}
|
||||
|
||||
try {
|
||||
$objectKey = ($this->path ? $this->path . '/' : '') . $remoteName;
|
||||
$fileSize = filesize($localPath);
|
||||
|
||||
// For files > 100 MB use multipart upload, otherwise single PUT
|
||||
if ($fileSize > 100 * 1024 * 1024) {
|
||||
$this->multipartUpload($localPath, $objectKey, $fileSize);
|
||||
} else {
|
||||
$this->singleUpload($localPath, $objectKey);
|
||||
}
|
||||
|
||||
$remotePath = 's3://' . $this->bucket . '/' . $objectKey;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Uploaded to S3: ' . $remotePath,
|
||||
'remote_path' => $remotePath,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => 'S3 upload failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function testConnection(): array
|
||||
{
|
||||
if (empty($this->accessKey) || empty($this->secretKey) || empty($this->bucket)) {
|
||||
return ['success' => false, 'message' => 'S3 credentials or bucket not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
// HEAD bucket to test access
|
||||
$url = $this->getBucketUrl();
|
||||
$headers = $this->signRequest('HEAD', $url, '');
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code === 200 || $code === 301) {
|
||||
return ['success' => true, 'message' => 'Connected to S3 bucket: ' . $this->bucket];
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => 'S3 returned HTTP ' . $code . ' for bucket ' . $this->bucket];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single PUT upload for files <= 100 MB.
|
||||
*/
|
||||
private function singleUpload(string $localPath, string $objectKey): void
|
||||
{
|
||||
$url = $this->getObjectUrl($objectKey);
|
||||
$fileContent = file_get_contents($localPath);
|
||||
$contentHash = hash('sha256', $fileContent);
|
||||
$headers = $this->signRequest('PUT', $url, $contentHash, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Length' => (string) strlen($fileContent),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => 'PUT',
|
||||
CURLOPT_POSTFIELDS => $fileContent,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 600,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
if (curl_errno($ch)) {
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
throw new \RuntimeException('cURL error: ' . $error);
|
||||
}
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($code < 200 || $code >= 300) {
|
||||
throw new \RuntimeException('S3 PUT failed (HTTP ' . $code . '): ' . substr($response, 0, 500));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart upload for files > 100 MB.
|
||||
*/
|
||||
private function multipartUpload(string $localPath, string $objectKey, int $fileSize): void
|
||||
{
|
||||
$chunkSize = 10 * 1024 * 1024; // 10 MB parts
|
||||
|
||||
// Step 1: Initiate multipart upload
|
||||
$uploadId = $this->initiateMultipart($objectKey);
|
||||
|
||||
$handle = fopen($localPath, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Cannot open file: ' . $localPath);
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
$partNum = 1;
|
||||
|
||||
try {
|
||||
// Step 2: Upload parts
|
||||
while (!feof($handle)) {
|
||||
$chunk = fread($handle, $chunkSize);
|
||||
|
||||
if ($chunk === false || $chunk === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
$etag = $this->uploadPart($objectKey, $uploadId, $partNum, $chunk);
|
||||
$parts[] = ['PartNumber' => $partNum, 'ETag' => $etag];
|
||||
$partNum++;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
// Step 3: Complete multipart upload
|
||||
$this->completeMultipart($objectKey, $uploadId, $parts);
|
||||
} catch (\Throwable $e) {
|
||||
fclose($handle);
|
||||
// Abort the multipart upload on failure
|
||||
$this->abortMultipart($objectKey, $uploadId);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function initiateMultipart(string $objectKey): string
|
||||
{
|
||||
$url = $this->getObjectUrl($objectKey) . '?uploads';
|
||||
$contentHash = hash('sha256', '');
|
||||
$headers = $this->signRequest('POST', $url, $contentHash, [
|
||||
'Content-Type' => 'application/zip',
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => '',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code < 200 || $code >= 300) {
|
||||
throw new \RuntimeException('Initiate multipart failed (HTTP ' . $code . ')');
|
||||
}
|
||||
|
||||
// Parse UploadId from XML response
|
||||
preg_match('/<UploadId>([^<]+)<\/UploadId>/', $response, $matches);
|
||||
|
||||
if (empty($matches[1])) {
|
||||
throw new \RuntimeException('No UploadId in initiate response');
|
||||
}
|
||||
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
private function uploadPart(string $objectKey, string $uploadId, int $partNumber, string $data): string
|
||||
{
|
||||
$url = $this->getObjectUrl($objectKey) . '?partNumber=' . $partNumber . '&uploadId=' . urlencode($uploadId);
|
||||
$contentHash = hash('sha256', $data);
|
||||
$headers = $this->signRequest('PUT', $url, $contentHash, [
|
||||
'Content-Length' => (string) strlen($data),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => 'PUT',
|
||||
CURLOPT_POSTFIELDS => $data,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$etag) {
|
||||
if (stripos($header, 'ETag:') === 0) {
|
||||
$etag = trim(substr($header, 5));
|
||||
}
|
||||
|
||||
return strlen($header);
|
||||
},
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
]);
|
||||
|
||||
$etag = '';
|
||||
curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code < 200 || $code >= 300) {
|
||||
throw new \RuntimeException('Upload part ' . $partNumber . ' failed (HTTP ' . $code . ')');
|
||||
}
|
||||
|
||||
return $etag;
|
||||
}
|
||||
|
||||
private function completeMultipart(string $objectKey, string $uploadId, array $parts): void
|
||||
{
|
||||
$xml = '<CompleteMultipartUpload>';
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$xml .= '<Part>'
|
||||
. '<PartNumber>' . $part['PartNumber'] . '</PartNumber>'
|
||||
. '<ETag>' . $part['ETag'] . '</ETag>'
|
||||
. '</Part>';
|
||||
}
|
||||
|
||||
$xml .= '</CompleteMultipartUpload>';
|
||||
|
||||
$url = $this->getObjectUrl($objectKey) . '?uploadId=' . urlencode($uploadId);
|
||||
$contentHash = hash('sha256', $xml);
|
||||
$headers = $this->signRequest('POST', $url, $contentHash, [
|
||||
'Content-Type' => 'application/xml',
|
||||
'Content-Length' => (string) strlen($xml),
|
||||
]);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $xml,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code < 200 || $code >= 300) {
|
||||
throw new \RuntimeException('Complete multipart failed (HTTP ' . $code . '): ' . substr($response, 0, 500));
|
||||
}
|
||||
}
|
||||
|
||||
private function abortMultipart(string $objectKey, string $uploadId): void
|
||||
{
|
||||
$url = $this->getObjectUrl($objectKey) . '?uploadId=' . urlencode($uploadId);
|
||||
$contentHash = hash('sha256', '');
|
||||
$headers = $this->signRequest('DELETE', $url, $contentHash);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
// ── AWS Signature V4 ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sign a request using AWS Signature V4.
|
||||
*
|
||||
* @return string[] Array of HTTP headers ready for cURL
|
||||
*/
|
||||
private function signRequest(string $method, string $url, string $payloadHash, array $extraHeaders = []): array
|
||||
{
|
||||
$parsed = parse_url($url);
|
||||
$host = $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : '');
|
||||
$path = $parsed['path'] ?? '/';
|
||||
$query = $parsed['query'] ?? '';
|
||||
|
||||
$now = gmdate('Ymd\THis\Z');
|
||||
$dateOnly = gmdate('Ymd');
|
||||
$scope = $dateOnly . '/' . $this->region . '/' . self::SERVICE . '/aws4_request';
|
||||
|
||||
// Canonical headers
|
||||
$headers = array_merge([
|
||||
'host' => $host,
|
||||
'x-amz-content-sha256' => $payloadHash,
|
||||
'x-amz-date' => $now,
|
||||
], array_change_key_case($extraHeaders, CASE_LOWER));
|
||||
|
||||
ksort($headers);
|
||||
|
||||
$signedHeaders = implode(';', array_keys($headers));
|
||||
$canonicalHeaders = '';
|
||||
|
||||
foreach ($headers as $k => $v) {
|
||||
$canonicalHeaders .= $k . ':' . trim($v) . "\n";
|
||||
}
|
||||
|
||||
// Canonical query string (sorted)
|
||||
$queryParts = [];
|
||||
|
||||
if (!empty($query)) {
|
||||
parse_str($query, $qp);
|
||||
ksort($qp);
|
||||
|
||||
foreach ($qp as $k => $v) {
|
||||
$queryParts[] = rawurlencode($k) . '=' . rawurlencode($v);
|
||||
}
|
||||
}
|
||||
|
||||
$canonicalQuery = implode('&', $queryParts);
|
||||
|
||||
// Canonical request
|
||||
$canonicalRequest = implode("\n", [
|
||||
$method,
|
||||
$path,
|
||||
$canonicalQuery,
|
||||
$canonicalHeaders,
|
||||
$signedHeaders,
|
||||
$payloadHash,
|
||||
]);
|
||||
|
||||
// String to sign
|
||||
$stringToSign = implode("\n", [
|
||||
'AWS4-HMAC-SHA256',
|
||||
$now,
|
||||
$scope,
|
||||
hash('sha256', $canonicalRequest),
|
||||
]);
|
||||
|
||||
// Signing key
|
||||
$kDate = hash_hmac('sha256', $dateOnly, 'AWS4' . $this->secretKey, true);
|
||||
$kRegion = hash_hmac('sha256', $this->region, $kDate, true);
|
||||
$kService = hash_hmac('sha256', self::SERVICE, $kRegion, true);
|
||||
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
|
||||
|
||||
$signature = hash_hmac('sha256', $stringToSign, $kSigning);
|
||||
|
||||
// Authorization header
|
||||
$authHeader = 'AWS4-HMAC-SHA256 '
|
||||
. 'Credential=' . $this->accessKey . '/' . $scope . ', '
|
||||
. 'SignedHeaders=' . $signedHeaders . ', '
|
||||
. 'Signature=' . $signature;
|
||||
|
||||
// Build cURL header array
|
||||
$curlHeaders = [
|
||||
'Authorization: ' . $authHeader,
|
||||
'x-amz-content-sha256: ' . $payloadHash,
|
||||
'x-amz-date: ' . $now,
|
||||
];
|
||||
|
||||
foreach ($extraHeaders as $k => $v) {
|
||||
$curlHeaders[] = $k . ': ' . $v;
|
||||
}
|
||||
|
||||
return $curlHeaders;
|
||||
}
|
||||
|
||||
private function getBucketUrl(): string
|
||||
{
|
||||
if ($this->endpoint === self::DEFAULT_ENDPOINT) {
|
||||
return 'https://' . $this->bucket . '.s3.' . $this->region . '.amazonaws.com/';
|
||||
}
|
||||
|
||||
// Path-style for custom endpoints (MinIO, Wasabi, B2)
|
||||
return rtrim($this->endpoint, '/') . '/' . $this->bucket . '/';
|
||||
}
|
||||
|
||||
private function getObjectUrl(string $objectKey): string
|
||||
{
|
||||
if ($this->endpoint === self::DEFAULT_ENDPOINT) {
|
||||
return 'https://' . $this->bucket . '.s3.' . $this->region . '.amazonaws.com/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
return rtrim($this->endpoint, '/') . '/' . $this->bucket . '/' . ltrim($objectKey, '/');
|
||||
}
|
||||
}
|
||||
@@ -1,570 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* AJAX step-based backup engine for shared hosting.
|
||||
*
|
||||
* Each call to runStep() performs one unit of work within the PHP time
|
||||
* limit, saves state, and returns. The browser JS fires the next step.
|
||||
*
|
||||
* This overcomes max_execution_time restrictions on shared hosting
|
||||
* where ini_set() and set_time_limit() are disabled.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class SteppedBackupEngine
|
||||
{
|
||||
/**
|
||||
* Initialize a new stepped backup session.
|
||||
*
|
||||
* @return array{session_id: string, phase: string, progress: int, message: string}
|
||||
*/
|
||||
public function init(int $profileId, string $description = '', string $origin = 'backend'): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Load profile
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokobackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
|
||||
if (!$profile) {
|
||||
return ['error' => true, 'message' => 'Profile not found: ' . $profileId];
|
||||
}
|
||||
|
||||
// Create session
|
||||
$session = SteppedSession::create();
|
||||
$session->profileId = $profileId;
|
||||
$session->origin = $origin;
|
||||
$session->backupType = $profile->backup_type;
|
||||
|
||||
// Parse profile settings
|
||||
$session->excludeDirs = $this->parseNewlineList($profile->exclude_dirs ?? '');
|
||||
$session->excludeFiles = $this->parseNewlineList($profile->exclude_files ?? '');
|
||||
$session->excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
|
||||
$session->backupDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups';
|
||||
$session->remoteStorage = $profile->remote_storage ?? 'none';
|
||||
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
||||
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
|
||||
|
||||
// Resolve placeholders in directory and filename
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
$backupDir = $this->resolveBackupDir($resolver->resolve($session->backupDir));
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
mkdir($backupDir, 0755, true);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
$archiveName = $resolver->resolve($nameFormat) . '.zip';
|
||||
|
||||
$session->archivePath = $backupDir . '/' . $archiveName;
|
||||
$session->archiveName = $archiveName;
|
||||
$session->description = $description ?: ($profile->title . ' — ' . $now);
|
||||
|
||||
// Create backup record
|
||||
$record = (object) [
|
||||
'profile_id' => $profileId,
|
||||
'description' => $session->description,
|
||||
'status' => 'running',
|
||||
'origin' => $origin,
|
||||
'backup_type' => $profile->backup_type,
|
||||
'archivename' => $archiveName,
|
||||
'absolute_path' => $session->archivePath,
|
||||
'total_size' => 0,
|
||||
'db_size' => 0,
|
||||
'files_count' => 0,
|
||||
'tables_count' => 0,
|
||||
'multipart' => 0,
|
||||
'tag' => $tag,
|
||||
'backupstart' => $now,
|
||||
'backupend' => '0000-00-00 00:00:00',
|
||||
'filesexist' => 0,
|
||||
'remote_filename' => '',
|
||||
'log' => '',
|
||||
];
|
||||
|
||||
$db->insertObject('#__mokobackup_records', $record, 'id');
|
||||
$session->recordId = $record->id;
|
||||
|
||||
// Determine what work needs to be done and estimate steps
|
||||
$totalSteps = 1; // init step
|
||||
|
||||
if ($profile->backup_type !== 'files') {
|
||||
// Count tables for database phase
|
||||
$tables = $this->getSiteTables($session->excludeTables);
|
||||
$session->tables = $tables;
|
||||
$totalSteps += count($tables); // one step per table
|
||||
}
|
||||
|
||||
if ($profile->backup_type !== 'database') {
|
||||
// Scan files and split into batches
|
||||
$scanner = new FileScanner(JPATH_ROOT, $session->excludeDirs, $session->excludeFiles);
|
||||
$allFiles = $scanner->scan();
|
||||
$session->filesCount = count($allFiles);
|
||||
$session->fileBatches = array_chunk($allFiles, $session->batchSize);
|
||||
$totalSteps += count($session->fileBatches); // one step per batch
|
||||
}
|
||||
|
||||
$totalSteps += 1; // finalize step
|
||||
$totalSteps += ($session->remoteStorage !== 'none') ? 1 : 0; // upload step
|
||||
|
||||
$session->totalSteps = $totalSteps;
|
||||
$session->currentStep = 1;
|
||||
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
|
||||
$session->log('Backup initialized: ' . $session->description);
|
||||
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
|
||||
$session->statusMessage = 'Initialized — starting backup...';
|
||||
$session->save();
|
||||
|
||||
return [
|
||||
'session_id' => $session->sessionId,
|
||||
'phase' => $session->phase,
|
||||
'progress' => $session->getProgress(),
|
||||
'message' => $session->statusMessage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the next step of a backup session.
|
||||
*
|
||||
* @return array{session_id: string, phase: string, progress: int, message: string, done?: bool}
|
||||
*/
|
||||
public function runStep(string $sessionId): array
|
||||
{
|
||||
$session = SteppedSession::load($sessionId);
|
||||
|
||||
if (!$session) {
|
||||
return ['error' => true, 'message' => 'Session not found: ' . $sessionId];
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($session->phase) {
|
||||
case 'database':
|
||||
$this->stepDatabase($session);
|
||||
break;
|
||||
|
||||
case 'files':
|
||||
$this->stepFiles($session);
|
||||
break;
|
||||
|
||||
case 'finalize':
|
||||
$this->stepFinalize($session);
|
||||
break;
|
||||
|
||||
case 'upload':
|
||||
$this->stepUpload($session);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
$session->destroy();
|
||||
|
||||
return [
|
||||
'session_id' => $sessionId,
|
||||
'phase' => 'complete',
|
||||
'progress' => 100,
|
||||
'message' => 'Backup complete: ' . $session->archiveName,
|
||||
'done' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$session->save();
|
||||
|
||||
return [
|
||||
'session_id' => $sessionId,
|
||||
'phase' => $session->phase,
|
||||
'progress' => $session->getProgress(),
|
||||
'message' => $session->statusMessage,
|
||||
'done' => $session->phase === 'complete',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$session->log('FATAL: ' . $e->getMessage());
|
||||
$this->failRecord($session, $e->getMessage());
|
||||
$session->destroy();
|
||||
|
||||
return ['error' => true, 'message' => 'Backup failed: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Database phase: dump one table per step.
|
||||
*/
|
||||
private function stepDatabase(SteppedSession $session): void
|
||||
{
|
||||
if ($session->tableIndex >= count($session->tables)) {
|
||||
// Database phase complete, move to files or finalize
|
||||
$session->phase = ($session->backupType !== 'database') ? 'files' : 'finalize';
|
||||
$session->tablesCount = $session->tableIndex;
|
||||
$session->log('Database dump complete: ' . $session->tablesCount . ' tables');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $session->tables[$session->tableIndex];
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Dump this single table
|
||||
$dumper = new DatabaseDumper([]);
|
||||
$sql = $this->dumpSingleTable($db, $table);
|
||||
|
||||
// Append to a temp SQL file that will be added to ZIP in finalize
|
||||
$sqlFile = $session->archivePath . '.sql';
|
||||
$flags = $session->tableIndex === 0 ? 0 : FILE_APPEND;
|
||||
|
||||
if ($session->tableIndex === 0) {
|
||||
$header = "-- MokoJoomBackup Database Dump\n"
|
||||
. "-- Generated: " . date('Y-m-d H:i:s') . "\n"
|
||||
. "-- Prefix: " . $db->getPrefix() . "\n\n"
|
||||
. "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n"
|
||||
. "SET time_zone = \"+00:00\";\n\n";
|
||||
file_put_contents($sqlFile, $header);
|
||||
$flags = FILE_APPEND;
|
||||
}
|
||||
|
||||
file_put_contents($sqlFile, $sql, $flags);
|
||||
$session->dbSize += strlen($sql);
|
||||
|
||||
$session->tableIndex++;
|
||||
$session->currentStep++;
|
||||
$session->statusMessage = 'Dumping table ' . $session->tableIndex . '/' . count($session->tables) . ': ' . $table;
|
||||
$session->log('Dumped table: ' . $table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Files phase: add one batch of files to ZIP per step.
|
||||
*/
|
||||
private function stepFiles(SteppedSession $session): void
|
||||
{
|
||||
if ($session->batchIndex >= count($session->fileBatches)) {
|
||||
$session->phase = 'finalize';
|
||||
$session->log('Files phase complete: ' . $session->filesCount . ' files in ' . count($session->fileBatches) . ' batches');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$batch = $session->fileBatches[$session->batchIndex];
|
||||
$zip = new \ZipArchive();
|
||||
$mode = $session->batchIndex === 0
|
||||
? (\ZipArchive::CREATE | \ZipArchive::OVERWRITE)
|
||||
: \ZipArchive::CREATE;
|
||||
|
||||
if ($zip->open($session->archivePath, $mode) !== true) {
|
||||
throw new \RuntimeException('Cannot open archive for writing');
|
||||
}
|
||||
|
||||
$added = 0;
|
||||
|
||||
foreach ($batch as $relativePath) {
|
||||
$fullPath = JPATH_ROOT . '/' . $relativePath;
|
||||
|
||||
if (is_file($fullPath) && is_readable($fullPath)) {
|
||||
$zip->addFile($fullPath, $relativePath);
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
$session->batchIndex++;
|
||||
$session->currentStep++;
|
||||
$batchNum = $session->batchIndex;
|
||||
$totalBatches = count($session->fileBatches);
|
||||
$session->statusMessage = "Adding files batch {$batchNum}/{$totalBatches} ({$added} files)";
|
||||
$session->log("Files batch {$batchNum}: {$added} files added");
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize phase: add database.sql to ZIP, apply MokoRestore wrapper.
|
||||
*/
|
||||
private function stepFinalize(SteppedSession $session): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($session->archivePath, \ZipArchive::CREATE) !== true) {
|
||||
throw new \RuntimeException('Cannot open archive for finalization');
|
||||
}
|
||||
|
||||
// Add database dump if it exists
|
||||
$sqlFile = $session->archivePath . '.sql';
|
||||
|
||||
if (is_file($sqlFile)) {
|
||||
$zip->addFile($sqlFile, 'database.sql');
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
// Clean up temp SQL file
|
||||
if (is_file($sqlFile)) {
|
||||
@unlink($sqlFile);
|
||||
}
|
||||
|
||||
$totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0;
|
||||
|
||||
// MokoRestore wrapper
|
||||
if ($session->includeMokoRestore) {
|
||||
$session->log('Wrapping with MokoRestore script...');
|
||||
$mokoRestorePath = $session->archivePath . '.mokorestore.zip';
|
||||
MokoRestore::wrap($session->archivePath, $mokoRestorePath);
|
||||
@unlink($session->archivePath);
|
||||
rename($mokoRestorePath, $session->archivePath);
|
||||
$totalSize = filesize($session->archivePath);
|
||||
$session->log('MokoRestore archive created');
|
||||
}
|
||||
|
||||
// Update record
|
||||
$db = Factory::getDbo();
|
||||
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
||||
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'total_size' => $totalSize,
|
||||
'db_size' => $session->dbSize,
|
||||
'files_count' => $session->filesCount,
|
||||
'tables_count' => $session->tablesCount,
|
||||
'filesexist' => 1,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokobackup_records', $update, 'id');
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete';
|
||||
$session->statusMessage = 'Archive finalized: ' . $sizeHuman;
|
||||
$session->log('Archive finalized: ' . $sizeHuman);
|
||||
|
||||
if ($session->phase === 'complete') {
|
||||
$this->completeRecord($session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload phase: send archive to remote storage.
|
||||
*/
|
||||
private function stepUpload(SteppedSession $session): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Reload profile for remote settings
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokobackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $session->profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
|
||||
$uploader = match ($session->remoteStorage) {
|
||||
'ftp' => new FtpUploader($profile),
|
||||
'google_drive' => new GoogleDriveUploader($profile),
|
||||
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
||||
};
|
||||
|
||||
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
|
||||
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||
|
||||
$remoteFilename = '';
|
||||
|
||||
if ($result['success']) {
|
||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||
$session->log('Remote upload complete: ' . $result['message']);
|
||||
|
||||
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||
@unlink($session->archivePath);
|
||||
$session->log('Local copy removed');
|
||||
}
|
||||
} else {
|
||||
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
||||
}
|
||||
|
||||
// Update record with remote filename
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'remote_filename' => $remoteFilename,
|
||||
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokobackup_records', $update, 'id');
|
||||
|
||||
$session->currentStep++;
|
||||
$session->phase = 'complete';
|
||||
$session->statusMessage = 'Backup complete';
|
||||
$this->completeRecord($session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the backup record as complete.
|
||||
*/
|
||||
private function completeRecord(SteppedSession $session): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$logContent = implode("\n", $session->log);
|
||||
|
||||
// Write log file alongside the archive
|
||||
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $session->archivePath);
|
||||
@file_put_contents($logPath, $logContent);
|
||||
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'status' => 'complete',
|
||||
'backupend' => date('Y-m-d H:i:s'),
|
||||
'log' => $logContent,
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokobackup_records', $update, 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the backup record as failed.
|
||||
*/
|
||||
private function failRecord(SteppedSession $session, string $error): void
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$update = (object) [
|
||||
'id' => $session->recordId,
|
||||
'status' => 'fail',
|
||||
'backupend' => date('Y-m-d H:i:s'),
|
||||
'log' => implode("\n", $session->log),
|
||||
];
|
||||
|
||||
$db->updateObject('#__mokobackup_records', $update, 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump a single table to SQL string.
|
||||
*/
|
||||
private function dumpSingleTable(object $db, string $table): string
|
||||
{
|
||||
$output = [];
|
||||
$output[] = '-- --------------------------------------------------------';
|
||||
$output[] = '-- Table: ' . $table;
|
||||
$output[] = '-- --------------------------------------------------------';
|
||||
$output[] = '';
|
||||
|
||||
// CREATE TABLE
|
||||
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
|
||||
$createRow = $db->loadRow();
|
||||
|
||||
if (!$createRow || empty($createRow[1])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
|
||||
$output[] = $createRow[1] . ';';
|
||||
$output[] = '';
|
||||
|
||||
// Data in chunks
|
||||
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
|
||||
$rowCount = (int) $db->loadResult();
|
||||
|
||||
if ($rowCount === 0) {
|
||||
$output[] = '-- (empty table)';
|
||||
$output[] = '';
|
||||
|
||||
return implode("\n", $output);
|
||||
}
|
||||
|
||||
$chunkSize = 500;
|
||||
|
||||
for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)->select('*')->from($db->quoteName($table)),
|
||||
$offset,
|
||||
$chunkSize
|
||||
);
|
||||
$rows = $db->loadAssocList();
|
||||
|
||||
if (empty($rows)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$values = [];
|
||||
|
||||
foreach ($row as $value) {
|
||||
$values[] = $value === null ? 'NULL' : $db->quote($value);
|
||||
}
|
||||
|
||||
$columns = array_map([$db, 'quoteName'], array_keys($row));
|
||||
$output[] = 'INSERT INTO ' . $db->quoteName($table)
|
||||
. ' (' . implode(', ', $columns) . ')'
|
||||
. ' VALUES (' . implode(', ', $values) . ');';
|
||||
}
|
||||
}
|
||||
|
||||
$output[] = '';
|
||||
|
||||
return implode("\n", $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get site tables (with prefix), excluding filtered tables.
|
||||
*/
|
||||
private function getSiteTables(array $excludeTables): array
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$prefix = $db->getPrefix();
|
||||
$tables = [];
|
||||
|
||||
foreach ($db->getTableList() as $table) {
|
||||
if (!str_starts_with($table, $prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$abstractName = '#__' . substr($table, strlen($prefix));
|
||||
|
||||
$excluded = false;
|
||||
|
||||
foreach ($excludeTables as $pattern) {
|
||||
if ($pattern === $abstractName || $pattern === $table) {
|
||||
$excluded = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$excluded) {
|
||||
$tables[] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
return $tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a backup directory path. Absolute paths are used as-is,
|
||||
* relative paths are resolved from JPATH_ROOT.
|
||||
*/
|
||||
private function resolveBackupDir(string $dir): string
|
||||
{
|
||||
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
|
||||
return rtrim($dir, '/\\');
|
||||
}
|
||||
|
||||
return JPATH_ROOT . '/' . $dir;
|
||||
}
|
||||
|
||||
private function parseNewlineList(string $text): array
|
||||
{
|
||||
if (empty($text)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map('trim', explode("\n", str_replace("\r", '', $text))),
|
||||
fn($line) => $line !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Manages state for AJAX step-based backups.
|
||||
*
|
||||
* On shared hosting where max_execution_time cannot be overridden,
|
||||
* the backup runs as a series of small AJAX requests. Each request
|
||||
* loads the session, does one chunk of work, saves state, and returns.
|
||||
* The browser JS fires the next step automatically.
|
||||
*
|
||||
* Phases: init → database → files → finalize → upload → complete
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class SteppedSession
|
||||
{
|
||||
public string $sessionId;
|
||||
public string $phase = 'init';
|
||||
public int $recordId = 0;
|
||||
public int $profileId = 0;
|
||||
public string $archivePath = '';
|
||||
public string $archiveName = '';
|
||||
public string $description = '';
|
||||
public string $origin = 'backend';
|
||||
|
||||
// Database phase tracking
|
||||
public array $tables = [];
|
||||
public int $tableIndex = 0;
|
||||
public int $tablesCount = 0;
|
||||
public int $dbSize = 0;
|
||||
|
||||
// Files phase tracking
|
||||
public array $fileBatches = [];
|
||||
public int $batchIndex = 0;
|
||||
public int $filesCount = 0;
|
||||
public int $batchSize = 200;
|
||||
|
||||
// Profile settings (cached so we don't re-query each step)
|
||||
public string $backupType = 'full';
|
||||
public string $backupDir = '';
|
||||
public array $excludeDirs = [];
|
||||
public array $excludeFiles = [];
|
||||
public array $excludeTables = [];
|
||||
public string $remoteStorage = 'none';
|
||||
public bool $includeMokoRestore = false;
|
||||
public bool $remoteKeepLocal = true;
|
||||
|
||||
// Progress
|
||||
public int $totalSteps = 0;
|
||||
public int $currentStep = 0;
|
||||
public string $statusMessage = '';
|
||||
public array $log = [];
|
||||
|
||||
private static function getSessionDir(): string
|
||||
{
|
||||
$dir = JPATH_ROOT . '/tmp/mokobackup-sessions';
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private static function getSessionPath(string $sessionId): string
|
||||
{
|
||||
// Sanitize session ID to prevent path traversal
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '', $sessionId);
|
||||
|
||||
return self::getSessionDir() . '/' . $safe . '.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session.
|
||||
*/
|
||||
public static function create(): self
|
||||
{
|
||||
$session = new self();
|
||||
$session->sessionId = 'mb_' . bin2hex(random_bytes(8));
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an existing session from disk.
|
||||
*/
|
||||
public static function load(string $sessionId): ?self
|
||||
{
|
||||
$path = self::getSessionPath($sessionId);
|
||||
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($path), true);
|
||||
|
||||
if (!$data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session = new self();
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (property_exists($session, $key)) {
|
||||
$session->$key = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session state to disk.
|
||||
*/
|
||||
public function save(): void
|
||||
{
|
||||
$path = self::getSessionPath($this->sessionId);
|
||||
file_put_contents($path, json_encode(get_object_vars($this), JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete session file.
|
||||
*/
|
||||
public function destroy(): void
|
||||
{
|
||||
$path = self::getSessionPath($this->sessionId);
|
||||
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log entry.
|
||||
*/
|
||||
public function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress percentage.
|
||||
*/
|
||||
public function getProgress(): int
|
||||
{
|
||||
if ($this->totalSteps <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return min(100, (int) round(($this->currentStep / $this->totalSteps) * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old session files (older than 24 hours).
|
||||
*/
|
||||
public static function cleanupOldSessions(): void
|
||||
{
|
||||
$dir = self::getSessionDir();
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoff = time() - 86400;
|
||||
|
||||
foreach (glob($dir . '/mb_*.json') as $file) {
|
||||
if (filemtime($file) < $cutoff) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?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\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class TarGzArchiver implements ArchiverInterface
|
||||
{
|
||||
private \PharData $tar;
|
||||
private string $tarPath;
|
||||
|
||||
public function open(string $path): void
|
||||
{
|
||||
// PharData creates .tar first, then we compress to .tar.gz
|
||||
// Strip .gz to get the .tar path for initial creation
|
||||
$this->tarPath = preg_replace('/\.gz$/', '', $path);
|
||||
|
||||
// Remove existing files to avoid "already exists" errors
|
||||
if (is_file($this->tarPath)) {
|
||||
@unlink($this->tarPath);
|
||||
}
|
||||
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
$this->tar = new \PharData($this->tarPath);
|
||||
}
|
||||
|
||||
public function addFromString(string $localName, string $contents): void
|
||||
{
|
||||
$this->tar->addFromString($localName, $contents);
|
||||
}
|
||||
|
||||
public function addFile(string $filePath, string $localName): void
|
||||
{
|
||||
$this->tar->addFile($filePath, $localName);
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
// Compress the .tar to .tar.gz
|
||||
$this->tar->compress(\Phar::GZ);
|
||||
|
||||
// Remove the uncompressed .tar
|
||||
if (is_file($this->tarPath)) {
|
||||
@unlink($this->tarPath);
|
||||
}
|
||||
}
|
||||
|
||||
public function getExtension(): string
|
||||
{
|
||||
return 'tar.gz';
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?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\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class ZipArchiver implements ArchiverInterface
|
||||
{
|
||||
private \ZipArchive $zip;
|
||||
|
||||
public function open(string $path): void
|
||||
{
|
||||
$this->zip = new \ZipArchive();
|
||||
|
||||
if ($this->zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Cannot create ZIP archive: ' . $path);
|
||||
}
|
||||
}
|
||||
|
||||
public function addFromString(string $localName, string $contents): void
|
||||
{
|
||||
$this->zip->addFromString($localName, $contents);
|
||||
}
|
||||
|
||||
public function addFile(string $filePath, string $localName): void
|
||||
{
|
||||
$this->zip->addFile($filePath, $localName);
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
$this->zip->close();
|
||||
}
|
||||
|
||||
public function getExtension(): string
|
||||
{
|
||||
return 'zip';
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?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\Extension;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\MVCComponent;
|
||||
|
||||
class MokoBackupComponent extends MVCComponent
|
||||
{
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,147 +0,0 @@
|
||||
<?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\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class DatabaseTablesField extends FormField
|
||||
{
|
||||
protected $type = 'DatabaseTables';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$db = Factory::getDbo();
|
||||
$tables = $db->getTableList();
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
// Parse current exclusions (newline-separated, with optional :data-only suffix)
|
||||
$excludeData = [];
|
||||
$excludeStructure = [];
|
||||
|
||||
if (!empty($this->value)) {
|
||||
$lines = array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value))));
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Normalize table name to real prefix for comparison
|
||||
if (str_ends_with($line, ':data-only')) {
|
||||
$tableName = str_replace('#__', $prefix, substr($line, 0, -10));
|
||||
$excludeData[$tableName] = true;
|
||||
} elseif (str_ends_with($line, ':structure-only')) {
|
||||
$tableName = str_replace('#__', $prefix, substr($line, 0, -15));
|
||||
$excludeStructure[$tableName] = true;
|
||||
} else {
|
||||
// No suffix = exclude both (backward compatible)
|
||||
$tableName = str_replace('#__', $prefix, $line);
|
||||
$excludeData[$tableName] = true;
|
||||
$excludeStructure[$tableName] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
||||
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$html = '<div class="mb-2">';
|
||||
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
|
||||
$html .= '<div class="form-text mb-2">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP') . '</div>';
|
||||
$html .= '<div class="table-responsive" style="max-height:400px; overflow-y:auto;">';
|
||||
$html .= '<table class="table table-sm table-hover mb-0">';
|
||||
$html .= '<thead class="sticky-top bg-white"><tr>';
|
||||
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleData" title="Toggle all data" /></th>';
|
||||
$html .= '<th class="w-1">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_DATA') . '</th>';
|
||||
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleStructure" title="Toggle all structure" /></th>';
|
||||
$html .= '<th class="w-1">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_STRUCTURE') . '</th>';
|
||||
$html .= '<th>' . Text::_('COM_MOKOBACKUP_FIELD_TABLE_NAME') . '</th>';
|
||||
$html .= '</tr></thead><tbody>';
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$dataChecked = isset($excludeData[$table]) ? ' checked' : '';
|
||||
$structureChecked = isset($excludeStructure[$table]) ? ' checked' : '';
|
||||
|
||||
// Convert to #__ notation for storage
|
||||
$storeValue = $table;
|
||||
|
||||
if (str_starts_with($table, $prefix)) {
|
||||
$storeValue = '#__' . substr($table, \strlen($prefix));
|
||||
}
|
||||
|
||||
$safeValue = htmlspecialchars($storeValue, ENT_QUOTES, 'UTF-8');
|
||||
$safeTable = htmlspecialchars($table, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$html .= '<tr>';
|
||||
$html .= '<td></td>';
|
||||
$html .= '<td><input type="checkbox" class="' . $id . '_data" value="' . $safeValue . '"' . $dataChecked . ' /></td>';
|
||||
$html .= '<td></td>';
|
||||
$html .= '<td><input type="checkbox" class="' . $id . '_structure" value="' . $safeValue . '"' . $structureChecked . ' /></td>';
|
||||
$html .= '<td><code>' . $safeTable . '</code></td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</tbody></table></div></div>';
|
||||
|
||||
// Script to sync checkboxes to hidden field
|
||||
$html .= <<<SCRIPT
|
||||
<script>
|
||||
(function() {
|
||||
var hidden = document.getElementById('{$id}');
|
||||
var dataCbs = document.querySelectorAll('.{$id}_data');
|
||||
var structCbs = document.querySelectorAll('.{$id}_structure');
|
||||
var toggleData = document.getElementById('{$id}_toggleData');
|
||||
var toggleStructure = document.getElementById('{$id}_toggleStructure');
|
||||
|
||||
function sync() {
|
||||
var result = {};
|
||||
dataCbs.forEach(function(cb) {
|
||||
if (cb.checked) result[cb.value] = (result[cb.value] || 0) | 1;
|
||||
});
|
||||
structCbs.forEach(function(cb) {
|
||||
if (cb.checked) result[cb.value] = (result[cb.value] || 0) | 2;
|
||||
});
|
||||
var lines = [];
|
||||
for (var table in result) {
|
||||
if (result[table] === 3) {
|
||||
lines.push(table);
|
||||
} else if (result[table] === 1) {
|
||||
lines.push(table + ':data-only');
|
||||
} else if (result[table] === 2) {
|
||||
lines.push(table + ':structure-only');
|
||||
}
|
||||
}
|
||||
hidden.value = lines.join('\\n');
|
||||
}
|
||||
|
||||
dataCbs.forEach(function(cb) { cb.addEventListener('change', sync); });
|
||||
structCbs.forEach(function(cb) { cb.addEventListener('change', sync); });
|
||||
|
||||
toggleData.addEventListener('change', function() {
|
||||
var state = this.checked;
|
||||
dataCbs.forEach(function(cb) { cb.checked = state; });
|
||||
sync();
|
||||
});
|
||||
|
||||
toggleStructure.addEventListener('change', function() {
|
||||
var state = this.checked;
|
||||
structCbs.forEach(function(cb) { cb.checked = state; });
|
||||
sync();
|
||||
});
|
||||
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
SCRIPT;
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
<?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
|
||||
*
|
||||
* Interactive directory tree field with checkboxes for exclude/include filtering.
|
||||
* Loads the directory tree from the server via AJAX (browseDir endpoint).
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class DirectoryFilterField extends FormField
|
||||
{
|
||||
protected $type = 'DirectoryFilter';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
||||
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
||||
$mode = htmlspecialchars((string) ($this->element['mode'] ?? 'exclude'), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Parse current values (newline-separated)
|
||||
$items = [];
|
||||
|
||||
if (!empty($this->value)) {
|
||||
$items = array_values(array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value)))));
|
||||
}
|
||||
|
||||
$itemsJson = json_encode($items);
|
||||
$jRoot = json_encode(JPATH_ROOT);
|
||||
|
||||
$labelExclude = Text::_('COM_MOKOBACKUP_FILTER_EXCLUDED');
|
||||
$labelInclude = Text::_('COM_MOKOBACKUP_FILTER_INCLUDED');
|
||||
$labelManual = Text::_('COM_MOKOBACKUP_FILTER_ADD_MANUAL');
|
||||
$addLabel = Text::_('JGLOBAL_FIELD_ADD');
|
||||
$placeholder = htmlspecialchars((string) ($this->element['hint'] ?? 'path/to/directory'), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div id="{$id}_wrap">
|
||||
<input type="hidden" name="{$name}" id="{$id}" value="" />
|
||||
|
||||
<!-- Manual entry row -->
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control" id="{$id}_manual" placeholder="{$placeholder}" />
|
||||
<button type="button" class="btn btn-outline-success" id="{$id}_addBtn">
|
||||
<span class="icon-plus" aria-hidden="true"></span> {$addLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected items (pills) -->
|
||||
<div id="{$id}_pills" class="mb-2 d-flex flex-wrap gap-1"></div>
|
||||
|
||||
<!-- Browsable tree -->
|
||||
<div class="card">
|
||||
<div class="card-header py-1 px-2 d-flex justify-content-between align-items-center">
|
||||
<small class="fw-bold text-muted" id="{$id}_cwd"></small>
|
||||
<button type="button" class="btn btn-sm btn-link p-0" id="{$id}_upBtn" style="display:none;">
|
||||
<span class="icon-arrow-up-4" aria-hidden="true"></span> ..
|
||||
</button>
|
||||
</div>
|
||||
<div id="{$id}_tree" class="list-group list-group-flush" style="max-height:300px; overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#{$id}_wrap .mb-dir-pill {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem;
|
||||
font-family: monospace; cursor: default;
|
||||
}
|
||||
#{$id}_wrap .mb-dir-pill.excluded { background: #f8d7da; color: #842029; border: 1px solid #f5c2c7; }
|
||||
#{$id}_wrap .mb-dir-pill.included { background: #d1e7dd; color: #0f5132; border: 1px solid #badbcc; }
|
||||
#{$id}_wrap .mb-dir-pill .btn-close { font-size: 0.6rem; }
|
||||
#{$id}_wrap .mb-dir-row { display: flex; align-items: center; padding: 0.35rem 0.75rem; gap: 0.5rem; border-bottom: 1px solid #eee; }
|
||||
#{$id}_wrap .mb-dir-row:hover { background: #f8f9fa; }
|
||||
#{$id}_wrap .mb-dir-row .mb-dir-name { cursor: pointer; flex: 1; font-size: 0.9rem; }
|
||||
#{$id}_wrap .mb-dir-row .mb-dir-name:hover { color: #0d6efd; text-decoration: underline; }
|
||||
#{$id}_wrap .mb-dir-check { width: 1rem; height: 1rem; cursor: pointer; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const id = '{$id}';
|
||||
const hidden = document.getElementById(id);
|
||||
const pills = document.getElementById(id + '_pills');
|
||||
const tree = document.getElementById(id + '_tree');
|
||||
const cwdEl = document.getElementById(id + '_cwd');
|
||||
const upBtn = document.getElementById(id + '_upBtn');
|
||||
const manualInput = document.getElementById(id + '_manual');
|
||||
const addBtn = document.getElementById(id + '_addBtn');
|
||||
const jRoot = {$jRoot};
|
||||
|
||||
let selected = new Set({$itemsJson});
|
||||
let currentPath = jRoot;
|
||||
let parentPath = null;
|
||||
|
||||
function sync() {
|
||||
hidden.value = Array.from(selected).join('\\n');
|
||||
renderPills();
|
||||
}
|
||||
|
||||
function renderPills() {
|
||||
while (pills.firstChild) pills.removeChild(pills.firstChild);
|
||||
selected.forEach(function(path) {
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'mb-dir-pill excluded';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'icon-folder';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
pill.appendChild(icon);
|
||||
|
||||
pill.appendChild(document.createTextNode(' ' + path + ' '));
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'btn-close btn-close-sm';
|
||||
closeBtn.setAttribute('aria-label', 'Remove');
|
||||
closeBtn.addEventListener('click', function() {
|
||||
selected.delete(path);
|
||||
sync();
|
||||
refreshTree();
|
||||
});
|
||||
pill.appendChild(closeBtn);
|
||||
|
||||
pills.appendChild(pill);
|
||||
});
|
||||
}
|
||||
|
||||
function toRelative(absPath) {
|
||||
if (absPath.indexOf(jRoot) === 0) {
|
||||
let rel = absPath.substring(jRoot.length);
|
||||
if (rel.charAt(0) === '/') rel = rel.substring(1);
|
||||
return rel;
|
||||
}
|
||||
return absPath;
|
||||
}
|
||||
|
||||
function setTreeMessage(text, cls) {
|
||||
while (tree.firstChild) tree.removeChild(tree.firstChild);
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'p-2 ' + cls;
|
||||
msg.textContent = text;
|
||||
tree.appendChild(msg);
|
||||
}
|
||||
|
||||
function loadDir(path) {
|
||||
setTreeMessage('Loading...', 'text-muted');
|
||||
currentPath = path;
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.append('task', 'ajax.browseDir');
|
||||
form.append('path', path);
|
||||
const tokenName = Joomla.getOptions('csrf.token') || '';
|
||||
if (tokenName) form.append(tokenName, '1');
|
||||
|
||||
fetch('index.php?option=com_mokobackup&format=json', {
|
||||
method: 'POST', body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
setTreeMessage(data.message || 'Error', 'text-danger');
|
||||
return;
|
||||
}
|
||||
parentPath = data.parent || null;
|
||||
cwdEl.textContent = data.current || path;
|
||||
upBtn.style.display = parentPath ? '' : 'none';
|
||||
renderTree(data.dirs || []);
|
||||
})
|
||||
.catch(function(err) {
|
||||
setTreeMessage('Error: ' + err.message, 'text-danger');
|
||||
});
|
||||
}
|
||||
|
||||
function refreshTree() {
|
||||
loadDir(currentPath);
|
||||
}
|
||||
|
||||
function renderTree(dirs) {
|
||||
while (tree.firstChild) tree.removeChild(tree.firstChild);
|
||||
if (dirs.length === 0) {
|
||||
setTreeMessage('(empty)', 'text-muted');
|
||||
return;
|
||||
}
|
||||
|
||||
dirs.forEach(function(dir) {
|
||||
const rel = toRelative(dir.path);
|
||||
const isExcluded = selected.has(rel);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mb-dir-row' + (isExcluded ? ' bg-danger bg-opacity-10' : '');
|
||||
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'mb-dir-check form-check-input';
|
||||
cb.checked = isExcluded;
|
||||
cb.title = isExcluded ? 'Excluded — uncheck to include' : 'Check to exclude';
|
||||
cb.addEventListener('change', function() {
|
||||
if (cb.checked) {
|
||||
selected.add(rel);
|
||||
} else {
|
||||
selected.delete(rel);
|
||||
}
|
||||
sync();
|
||||
refreshTree();
|
||||
});
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = isExcluded ? 'icon-unpublish text-danger' : 'icon-folder text-warning';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'mb-dir-name';
|
||||
nameEl.textContent = dir.name;
|
||||
nameEl.addEventListener('click', function() { loadDir(dir.path); });
|
||||
|
||||
row.appendChild(cb);
|
||||
row.appendChild(icon);
|
||||
row.appendChild(nameEl);
|
||||
tree.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
upBtn.addEventListener('click', function() {
|
||||
if (parentPath) loadDir(parentPath);
|
||||
});
|
||||
|
||||
addBtn.addEventListener('click', function() {
|
||||
const val = manualInput.value.trim();
|
||||
if (val && !selected.has(val)) {
|
||||
selected.add(val);
|
||||
manualInput.value = '';
|
||||
sync();
|
||||
refreshTree();
|
||||
}
|
||||
});
|
||||
|
||||
manualInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addBtn.click(); }
|
||||
});
|
||||
|
||||
sync();
|
||||
loadDir(jRoot);
|
||||
})();
|
||||
</script>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<?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\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class ExcludeListField extends FormField
|
||||
{
|
||||
protected $type = 'ExcludeList';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
||||
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
||||
$placeholder = htmlspecialchars((string) ($this->element['hint'] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Parse current values (newline-separated)
|
||||
$items = [];
|
||||
|
||||
if (!empty($this->value)) {
|
||||
$items = array_values(array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value)))));
|
||||
}
|
||||
|
||||
$html = '<div id="' . $id . '_wrapper">';
|
||||
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
|
||||
$html .= '<table class="table table-sm mb-1" id="' . $id . '_table">';
|
||||
$html .= '<tbody>';
|
||||
|
||||
foreach ($items as $item) {
|
||||
$safeItem = htmlspecialchars($item, ENT_QUOTES, 'UTF-8');
|
||||
$html .= '<tr>';
|
||||
$html .= '<td><input type="text" class="form-control form-control-sm ' . $id . '_input" value="' . $safeItem . '" placeholder="' . $placeholder . '" /></td>';
|
||||
$html .= '<td class="w-1"><button type="button" class="btn btn-sm btn-outline-danger ' . $id . '_remove"><span class="icon-delete" aria-hidden="true"></span></button></td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
|
||||
$html .= '</tbody></table>';
|
||||
$html .= '<button type="button" class="btn btn-sm btn-outline-success" id="' . $id . '_add">';
|
||||
$html .= '<span class="icon-plus" aria-hidden="true"></span> ' . Text::_('JGLOBAL_FIELD_ADD') . '</button>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= <<<SCRIPT
|
||||
<script>
|
||||
(function() {
|
||||
var wrapper = document.getElementById('{$id}_wrapper');
|
||||
var hidden = document.getElementById('{$id}');
|
||||
var tbody = document.querySelector('#{$id}_table tbody');
|
||||
var addBtn = document.getElementById('{$id}_add');
|
||||
var placeholder = '{$placeholder}';
|
||||
|
||||
function sync() {
|
||||
var vals = [];
|
||||
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
|
||||
var v = inp.value.trim();
|
||||
if (v) vals.push(v);
|
||||
});
|
||||
hidden.value = vals.join('\\n');
|
||||
}
|
||||
|
||||
function addRow(value) {
|
||||
var tr = document.createElement('tr');
|
||||
var td1 = document.createElement('td');
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.className = 'form-control form-control-sm {$id}_input';
|
||||
inp.value = value || '';
|
||||
inp.placeholder = placeholder;
|
||||
inp.addEventListener('input', sync);
|
||||
td1.appendChild(inp);
|
||||
|
||||
var td2 = document.createElement('td');
|
||||
td2.className = 'w-1';
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-danger {$id}_remove';
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'icon-delete';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
btn.appendChild(icon);
|
||||
btn.addEventListener('click', function() { tr.remove(); sync(); });
|
||||
td2.appendChild(btn);
|
||||
|
||||
tr.appendChild(td1);
|
||||
tr.appendChild(td2);
|
||||
tbody.appendChild(tr);
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
addBtn.addEventListener('click', function() { addRow(''); });
|
||||
|
||||
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
|
||||
inp.addEventListener('input', sync);
|
||||
});
|
||||
|
||||
wrapper.querySelectorAll('.{$id}_remove').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
btn.closest('tr').remove();
|
||||
sync();
|
||||
});
|
||||
});
|
||||
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
SCRIPT;
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
<?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\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class FolderPickerField extends FormField
|
||||
{
|
||||
protected $type = 'FolderPicker';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8');
|
||||
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
||||
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
||||
$jRoot = JPATH_ROOT;
|
||||
|
||||
// Resolve to absolute for display
|
||||
$rawValue = $this->value ?: $this->default;
|
||||
|
||||
if ($rawValue && $rawValue[0] !== '/') {
|
||||
$absPath = $jRoot . '/' . $rawValue;
|
||||
} else {
|
||||
$absPath = $rawValue;
|
||||
}
|
||||
|
||||
$exists = is_dir($absPath);
|
||||
$statusClass = $exists ? 'text-success' : 'text-danger';
|
||||
$statusIcon = $exists ? 'icon-publish' : 'icon-unpublish';
|
||||
$statusText = $exists
|
||||
? Text::_('COM_MOKOBACKUP_FOLDER_EXISTS')
|
||||
: Text::_('COM_MOKOBACKUP_FOLDER_NOT_FOUND');
|
||||
$absPathSafe = htmlspecialchars($absPath, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div class="input-group">
|
||||
<input type="text" name="{$name}" id="{$id}" value="{$value}"
|
||||
class="form-control" maxlength="512"
|
||||
placeholder="/home/user/backups or administrator/components/com_mokobackup/backups" />
|
||||
<button type="button" class="btn btn-outline-secondary" id="{$id}_btn">
|
||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<small class="{$statusClass}">
|
||||
<span class="{$statusIcon}" aria-hidden="true"></span>
|
||||
{$statusText}: <code>{$absPathSafe}</code>
|
||||
</small>
|
||||
</div>
|
||||
<div id="{$id}_browser" class="card mt-2" style="display:none; max-height:300px; overflow-y:auto;">
|
||||
<div class="card-body p-2">
|
||||
<div id="{$id}_tree"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var fieldId = '{$id}';
|
||||
var btn = document.getElementById(fieldId + '_btn');
|
||||
var browser = document.getElementById(fieldId + '_browser');
|
||||
var tree = document.getElementById(fieldId + '_tree');
|
||||
var input = document.getElementById(fieldId);
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
if (browser.style.display !== 'none') {
|
||||
browser.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
browser.style.display = 'block';
|
||||
loadDir(input.value || '/');
|
||||
});
|
||||
|
||||
function loadDir(path) {
|
||||
tree.textContent = 'Loading...';
|
||||
|
||||
var form = new URLSearchParams();
|
||||
form.append('task', 'ajax.browseDir');
|
||||
form.append('path', path);
|
||||
|
||||
var tokenName = Joomla.getOptions('csrf.token') || '';
|
||||
if (tokenName) form.append(tokenName, '1');
|
||||
|
||||
fetch('index.php?option=com_mokobackup&format=json', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
tree.textContent = data.message || 'Error loading directory';
|
||||
return;
|
||||
}
|
||||
renderTree(data, path);
|
||||
})
|
||||
.catch(function(err) {
|
||||
tree.textContent = 'Error: ' + err.message;
|
||||
});
|
||||
}
|
||||
|
||||
function renderTree(data, path) {
|
||||
while (tree.firstChild) tree.removeChild(tree.firstChild);
|
||||
|
||||
var list = document.createElement('div');
|
||||
list.className = 'list-group list-group-flush';
|
||||
|
||||
if (data.parent) {
|
||||
var up = document.createElement('a');
|
||||
up.href = '#';
|
||||
up.className = 'list-group-item list-group-item-action py-1';
|
||||
var upIcon = document.createElement('span');
|
||||
upIcon.className = 'icon-arrow-up-4';
|
||||
upIcon.setAttribute('aria-hidden', 'true');
|
||||
up.appendChild(upIcon);
|
||||
up.appendChild(document.createTextNode(' ..'));
|
||||
up.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
loadDir(data.parent);
|
||||
});
|
||||
list.appendChild(up);
|
||||
}
|
||||
|
||||
(data.dirs || []).forEach(function(dir) {
|
||||
var item = document.createElement('a');
|
||||
item.href = '#';
|
||||
item.className = 'list-group-item list-group-item-action py-1';
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'icon-folder';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(icon);
|
||||
item.appendChild(document.createTextNode(' ' + dir.name));
|
||||
item.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
input.value = dir.path;
|
||||
loadDir(dir.path);
|
||||
});
|
||||
item.addEventListener('dblclick', function(e) {
|
||||
e.preventDefault();
|
||||
input.value = dir.path;
|
||||
browser.style.display = 'none';
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
tree.appendChild(list);
|
||||
|
||||
var info = document.createElement('div');
|
||||
info.className = 'mt-2 p-1';
|
||||
var small = document.createElement('small');
|
||||
small.className = 'text-muted';
|
||||
small.textContent = 'Current: ' + (data.current || path);
|
||||
info.appendChild(small);
|
||||
tree.appendChild(info);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?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\AdminModel;
|
||||
|
||||
class BackupModel extends AdminModel
|
||||
{
|
||||
public function getForm($data = [], $loadData = true)
|
||||
{
|
||||
$form = $this->loadForm(
|
||||
'com_mokobackup.backup',
|
||||
'backup',
|
||||
['control' => 'jform', 'load_data' => $loadData]
|
||||
);
|
||||
|
||||
return $form ?: false;
|
||||
}
|
||||
|
||||
protected function loadFormData(): object
|
||||
{
|
||||
$data = Factory::getApplication()->getUserState('com_mokobackup.edit.backup.data', []);
|
||||
|
||||
if (empty($data)) {
|
||||
$data = $this->getItem();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getTable($name = 'Backup', $prefix = 'Administrator', $options = [])
|
||||
{
|
||||
return parent::getTable($name, $prefix, $options);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?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\MVC\Model\ListModel;
|
||||
use Joomla\Database\QueryInterface;
|
||||
|
||||
class BackupsModel extends ListModel
|
||||
{
|
||||
public function __construct($config = [])
|
||||
{
|
||||
if (empty($config['filter_fields'])) {
|
||||
$config['filter_fields'] = [
|
||||
'id', 'a.id',
|
||||
'profile_id', 'a.profile_id',
|
||||
'status', 'a.status',
|
||||
'origin', 'a.origin',
|
||||
'backup_type', 'a.backup_type',
|
||||
'total_size', 'a.total_size',
|
||||
'backupstart', 'a.backupstart',
|
||||
'backupend', 'a.backupend',
|
||||
];
|
||||
}
|
||||
|
||||
parent::__construct($config);
|
||||
}
|
||||
|
||||
protected function getListQuery(): QueryInterface
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokobackup_records', 'a'));
|
||||
|
||||
// Join profile title
|
||||
$query->select($db->quoteName('p.title', 'profile_title'))
|
||||
->join('LEFT', $db->quoteName('#__mokobackup_profiles', 'p') . ' ON p.id = a.profile_id');
|
||||
|
||||
// Filter by status
|
||||
$status = $this->getState('filter.status');
|
||||
|
||||
if (!empty($status)) {
|
||||
$query->where($db->quoteName('a.status') . ' = ' . $db->quote($status));
|
||||
}
|
||||
|
||||
// Filter by profile
|
||||
$profileId = $this->getState('filter.profile_id');
|
||||
|
||||
if (is_numeric($profileId)) {
|
||||
$query->where($db->quoteName('a.profile_id') . ' = ' . (int) $profileId);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
$search = $this->getState('filter.search');
|
||||
|
||||
if (!empty($search)) {
|
||||
$search = $db->quote('%' . $db->escape(trim($search), true) . '%');
|
||||
$query->where('(' . $db->quoteName('a.description') . ' LIKE ' . $search . ')');
|
||||
}
|
||||
|
||||
$orderCol = $this->state->get('list.ordering', 'a.backupstart');
|
||||
$orderDir = $this->state->get('list.direction', 'DESC');
|
||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function populateState($ordering = 'a.backupstart', $direction = 'DESC'): void
|
||||
{
|
||||
parent::populateState($ordering, $direction);
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
<?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 — check the default path
|
||||
$defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups';
|
||||
$backupDir = $defaultDir;
|
||||
|
||||
// If profiles use a custom directory, check that instead
|
||||
$db2 = $this->getDatabase();
|
||||
$qDir = $db2->getQuery(true)
|
||||
->select($db2->quoteName('backup_dir'))
|
||||
->from($db2->quoteName('#__mokobackup_profiles'))
|
||||
->where($db2->quoteName('published') . ' = 1')
|
||||
->where($db2->quoteName('backup_dir') . ' != ' . $db2->quote(''))
|
||||
->where($db2->quoteName('backup_dir') . ' IS NOT NULL');
|
||||
$db2->setQuery($qDir, 0, 1);
|
||||
$profileDir = $db2->loadResult();
|
||||
|
||||
if ($profileDir) {
|
||||
// Absolute paths used as-is, relative resolved from JPATH_ROOT
|
||||
if ($profileDir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $profileDir)) {
|
||||
$backupDir = rtrim($profileDir, '/\\');
|
||||
} else {
|
||||
$backupDir = JPATH_ROOT . '/' . $profileDir;
|
||||
}
|
||||
}
|
||||
|
||||
$writable = is_dir($backupDir) && is_writable($backupDir);
|
||||
$checks[] = (object) [
|
||||
'label' => 'Backup Directory',
|
||||
'status' => $writable,
|
||||
'detail' => ($writable ? 'Writable' : 'Not writable or missing') . ' — ' . $backupDir,
|
||||
];
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any profiles use the default (web-root) backup directory.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isUsingDefaultBackupDir(): bool
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$default = 'administrator/components/com_mokobackup/backups';
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokobackup_profiles'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote($default)
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
|
||||
$db->setQuery($query);
|
||||
|
||||
return (int) $db->loadResult() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() ?: [];
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?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\AdminModel;
|
||||
|
||||
class ProfileModel extends AdminModel
|
||||
{
|
||||
public function getForm($data = [], $loadData = true)
|
||||
{
|
||||
$form = $this->loadForm(
|
||||
'com_mokobackup.profile',
|
||||
'profile',
|
||||
['control' => 'jform', 'load_data' => $loadData]
|
||||
);
|
||||
|
||||
return $form ?: false;
|
||||
}
|
||||
|
||||
protected function loadFormData(): object
|
||||
{
|
||||
$data = Factory::getApplication()->getUserState('com_mokobackup.edit.profile.data', []);
|
||||
|
||||
if (empty($data)) {
|
||||
$data = $this->getItem();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getTable($name = 'Profile', $prefix = 'Administrator', $options = [])
|
||||
{
|
||||
return parent::getTable($name, $prefix, $options);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<?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\MVC\Model\ListModel;
|
||||
use Joomla\Database\QueryInterface;
|
||||
|
||||
class ProfilesModel extends ListModel
|
||||
{
|
||||
public function __construct($config = [])
|
||||
{
|
||||
if (empty($config['filter_fields'])) {
|
||||
$config['filter_fields'] = [
|
||||
'id', 'a.id',
|
||||
'title', 'a.title',
|
||||
'backup_type', 'a.backup_type',
|
||||
'published', 'a.published',
|
||||
'ordering', 'a.ordering',
|
||||
];
|
||||
}
|
||||
|
||||
parent::__construct($config);
|
||||
}
|
||||
|
||||
protected function getListQuery(): QueryInterface
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$query = $db->getQuery(true);
|
||||
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokobackup_profiles', 'a'));
|
||||
|
||||
$published = $this->getState('filter.published');
|
||||
|
||||
if (is_numeric($published)) {
|
||||
$query->where($db->quoteName('a.published') . ' = ' . (int) $published);
|
||||
}
|
||||
|
||||
$search = $this->getState('filter.search');
|
||||
|
||||
if (!empty($search)) {
|
||||
$search = $db->quote('%' . $db->escape(trim($search), true) . '%');
|
||||
$query->where('(' . $db->quoteName('a.title') . ' LIKE ' . $search . ')');
|
||||
}
|
||||
|
||||
$orderCol = $this->state->get('list.ordering', 'a.ordering');
|
||||
$orderDir = $this->state->get('list.direction', 'ASC');
|
||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function populateState($ordering = 'a.ordering', $direction = 'ASC'): void
|
||||
{
|
||||
parent::populateState($ordering, $direction);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
class BackupTable extends Table
|
||||
{
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokobackup_records', 'id', $db);
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->profile_id)) {
|
||||
$this->setError('Profile ID is required.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->backupstart) || $this->backupstart === '0000-00-00 00:00:00') {
|
||||
$this->backupstart = date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete($pk = null): bool
|
||||
{
|
||||
// Delete the archive file if it exists
|
||||
if (!empty($this->absolute_path) && is_file($this->absolute_path)) {
|
||||
@unlink($this->absolute_path);
|
||||
}
|
||||
|
||||
return parent::delete($pk);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?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\Table;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
class ProfileTable extends Table
|
||||
{
|
||||
public function __construct(DatabaseDriver $db)
|
||||
{
|
||||
parent::__construct('#__mokobackup_profiles', 'id', $db);
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->title)) {
|
||||
$this->setError('Profile title is required.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($this->backup_type)) {
|
||||
$this->backup_type = 'full';
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
if (empty($this->created) || $this->created === '0000-00-00 00:00:00') {
|
||||
$this->created = $now;
|
||||
}
|
||||
|
||||
$this->modified = $now;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,39 +0,0 @@
|
||||
<?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\Backup;
|
||||
|
||||
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
|
||||
{
|
||||
protected $item;
|
||||
protected $form;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->item = $this->get('Item');
|
||||
$this->form = $this->get('Form');
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUP_DETAIL'), 'database');
|
||||
ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokobackup&view=backups');
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,113 +0,0 @@
|
||||
<?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\Backups;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $items;
|
||||
protected $pagination;
|
||||
protected $state;
|
||||
public $filterForm;
|
||||
public $activeFilters = [];
|
||||
public $profiles = [];
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
$this->state = $this->get('State');
|
||||
$this->filterForm = $this->get('FilterForm');
|
||||
$this->activeFilters = $this->get('ActiveFilters');
|
||||
|
||||
// Load published profiles for the backup selector
|
||||
$db = Factory::getDbo();
|
||||
$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);
|
||||
$this->profiles = $db->loadObjectList() ?: [];
|
||||
|
||||
$this->checkUpdateSite();
|
||||
$this->addToolbar();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an info notice linking to the update site record so the user
|
||||
* can configure their download key for automatic updates.
|
||||
*/
|
||||
protected function checkUpdateSite(): void
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Find the update site ID linked to pkg_mokobackup
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('us.update_site_id'))
|
||||
->from($db->quoteName('#__update_sites', 'us'))
|
||||
->join(
|
||||
'INNER',
|
||||
$db->quoteName('#__update_sites_extensions', 'use')
|
||||
. ' ON ' . $db->quoteName('use.update_site_id') . ' = ' . $db->quoteName('us.update_site_id')
|
||||
)
|
||||
->join(
|
||||
'INNER',
|
||||
$db->quoteName('#__extensions', 'e')
|
||||
. ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('use.extension_id')
|
||||
)
|
||||
->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokobackup'))
|
||||
->where($db->quoteName('e.type') . ' = ' . $db->quote('package'))
|
||||
->setLimit(1);
|
||||
|
||||
$db->setQuery($query);
|
||||
$updateSiteId = (int) $db->loadResult();
|
||||
|
||||
if ($updateSiteId > 0) {
|
||||
$editUrl = Route::_(
|
||||
'index.php?option=com_installer&view=updatesites&task=updatesite.edit&id=' . $updateSiteId
|
||||
);
|
||||
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
Text::sprintf('COM_MOKOBACKUP_UPDATE_SITE_NOTICE', $editUrl),
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
Text::_('COM_MOKOBACKUP_UPDATE_SITE_MISSING'),
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Non-critical — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUPS_TITLE'), 'database');
|
||||
ToolbarHelper::custom('backups.start', 'download', '', 'COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW', false);
|
||||
ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOBACKUP_TOOLBAR_RESTORE', true);
|
||||
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOBACKUP_TOOLBAR_VERIFY', true);
|
||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
|
||||
ToolbarHelper::preferences('com_mokobackup');
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,50 +0,0 @@
|
||||
<?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 bool $defaultDirWarning = false;
|
||||
|
||||
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->defaultDirWarning = $model->isUsingDefaultBackupDir();
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOBACKUP_DASHBOARD_TITLE'), 'archive');
|
||||
ToolbarHelper::preferences('com_mokobackup');
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?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\Profile;
|
||||
|
||||
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
|
||||
{
|
||||
protected $item;
|
||||
protected $form;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->item = $this->get('Item');
|
||||
$this->form = $this->get('Form');
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
$isNew = empty($this->item->id);
|
||||
$title = $isNew ? 'COM_MOKOBACKUP_PROFILE_NEW' : 'COM_MOKOBACKUP_PROFILE_EDIT';
|
||||
|
||||
ToolbarHelper::title(Text::_($title), 'cog');
|
||||
ToolbarHelper::apply('profile.apply');
|
||||
ToolbarHelper::save('profile.save');
|
||||
ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,60 +0,0 @@
|
||||
<?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\Profiles;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
use Joomla\Component\MokoBackup\Administrator\Engine\AkeebaImporter;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
{
|
||||
protected $items;
|
||||
protected $pagination;
|
||||
protected $state;
|
||||
public $filterForm;
|
||||
public $activeFilters = [];
|
||||
public bool $akeebaDetected = false;
|
||||
|
||||
public function display($tpl = null): void
|
||||
{
|
||||
$this->items = $this->get('Items');
|
||||
$this->pagination = $this->get('Pagination');
|
||||
$this->state = $this->get('State');
|
||||
$this->filterForm = $this->get('FilterForm');
|
||||
$this->activeFilters = $this->get('ActiveFilters');
|
||||
|
||||
// Check if Akeeba Backup is installed
|
||||
$importer = new AkeebaImporter();
|
||||
$detection = $importer->detect();
|
||||
$this->akeebaDetected = $detection['profiles'];
|
||||
|
||||
$this->addToolbar();
|
||||
|
||||
parent::display($tpl);
|
||||
}
|
||||
|
||||
protected function addToolbar(): void
|
||||
{
|
||||
ToolbarHelper::title(Text::_('COM_MOKOBACKUP_PROFILES_TITLE'), 'cog');
|
||||
ToolbarHelper::addNew('profile.add');
|
||||
ToolbarHelper::editList('profile.edit');
|
||||
|
||||
if ($this->akeebaDetected) {
|
||||
ToolbarHelper::custom('profiles.importAkeeba', 'upload', '', 'COM_MOKOBACKUP_TOOLBAR_IMPORT_AKEEBA', false);
|
||||
}
|
||||
|
||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'profiles.delete');
|
||||
ToolbarHelper::preferences('com_mokobackup');
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,125 +0,0 @@
|
||||
<?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="main-card">
|
||||
<div class="card-body">
|
||||
<h2><?php echo $this->escape($this->item->description); ?></h2>
|
||||
|
||||
<table class="table table-striped">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_STATUS'); ?></th>
|
||||
<td>
|
||||
<?php
|
||||
$statusClass = match ($this->item->status) {
|
||||
'complete' => 'badge bg-success',
|
||||
'running' => 'badge bg-info',
|
||||
'fail' => 'badge bg-danger',
|
||||
default => 'badge bg-secondary',
|
||||
};
|
||||
?>
|
||||
<span class="<?php echo $statusClass; ?>"><?php echo $this->escape($this->item->status); ?></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_BACKUP_TYPE'); ?></th>
|
||||
<td><?php echo $this->escape($this->item->backup_type); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_ORIGIN'); ?></th>
|
||||
<td><?php echo $this->escape($this->item->origin); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_SIZE'); ?></th>
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('number.bytes', $this->item->total_size); ?>
|
||||
<?php if ($this->item->db_size > 0) : ?>
|
||||
<small class="text-muted">(<?php echo Text::_('COM_MOKOBACKUP_FIELD_DB_SIZE'); ?>: <?php echo HTMLHelper::_('number.bytes', $this->item->db_size); ?>)</small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_START'); ?></th>
|
||||
<td><?php echo HTMLHelper::_('date', $this->item->backupstart, Text::_('DATE_FORMAT_LC2')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_END'); ?></th>
|
||||
<td><?php echo HTMLHelper::_('date', $this->item->backupend, Text::_('DATE_FORMAT_LC2')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_ARCHIVE'); ?></th>
|
||||
<td><code><?php echo $this->escape($this->item->archivename); ?></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_PATH'); ?></th>
|
||||
<td><code><?php echo $this->escape($this->item->absolute_path); ?></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_FILES_COUNT'); ?></th>
|
||||
<td><?php echo (int) $this->item->files_count; ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_TABLES_COUNT'); ?></th>
|
||||
<td><?php echo (int) $this->item->tables_count; ?></td>
|
||||
</tr>
|
||||
<?php if (!empty($this->item->checksum)) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_CHECKSUM'); ?></th>
|
||||
<td><code class="font-monospace" style="font-size:0.85em;"><?php echo $this->escape($this->item->checksum); ?></code></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($this->item->remote_filename)) : ?>
|
||||
<tr>
|
||||
<th scope="row"><?php echo Text::_('COM_MOKOBACKUP_FIELD_REMOTE'); ?></th>
|
||||
<td><code><?php echo $this->escape($this->item->remote_filename); ?></code></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Backup Log -->
|
||||
<h4 class="mt-4"><?php echo Text::_('COM_MOKOBACKUP_VIEW_LOG'); ?></h4>
|
||||
<div id="mb-detail-log" class="bg-light p-3 rounded" style="max-height:400px; overflow-y:auto;">
|
||||
<pre id="mb-detail-log-body" style="white-space:pre-wrap; word-break:break-word; font-size:0.85rem; margin:0;">Loading...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var form = new URLSearchParams();
|
||||
form.append('task', 'ajax.viewLog');
|
||||
form.append('id', <?php echo (int) $this->item->id; ?>);
|
||||
form.append(<?php echo json_encode($ajaxToken); ?>, '1');
|
||||
|
||||
fetch(<?php echo json_encode($ajaxUrl); ?>, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
document.getElementById('mb-detail-log-body').textContent = data.error ? data.message : data.log;
|
||||
})
|
||||
.catch(function(err) {
|
||||
document.getElementById('mb-detail-log-body').textContent = 'Error: ' + err.message;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -1,341 +0,0 @@
|
||||
<?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\Layout\LayoutHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
HTMLHelper::_('behavior.multiselect');
|
||||
|
||||
$ajaxToken = Session::getFormToken();
|
||||
$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false);
|
||||
|
||||
$listOrder = $this->escape($this->state->get('list.ordering'));
|
||||
$listDirn = $this->escape($this->state->get('list.direction'));
|
||||
?>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokobackup&view=backups'); ?>" method="post" name="adminForm" id="adminForm">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div id="j-main-container" class="j-main-container">
|
||||
<!-- Profile selector for Backup Now -->
|
||||
<?php if (!empty($this->profiles)) : ?>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<label for="mb-profile-select" class="form-label mb-0 fw-bold">
|
||||
<?php echo Text::_('COM_MOKOBACKUP_BACKUP_PROFILE'); ?>:
|
||||
</label>
|
||||
<select id="mb-profile-select" class="form-select" style="max-width:300px;">
|
||||
<?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>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php echo LayoutHelper::render('joomla.searchtools.default', ['view' => $this]); ?>
|
||||
|
||||
<?php if (empty($this->items)) : ?>
|
||||
<div class="alert alert-info">
|
||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOBACKUP_NO_BACKUPS'); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<table class="table" id="backupList">
|
||||
<caption class="visually-hidden"><?php echo Text::_('COM_MOKOBACKUP_BACKUPS_TABLE_CAPTION'); ?></caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="w-1 text-center">
|
||||
<?php echo HTMLHelper::_('grid.checkall'); ?>
|
||||
</td>
|
||||
<th scope="col">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOBACKUP_HEADING_DESCRIPTION', 'a.description', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOBACKUP_HEADING_PROFILE', 'a.profile_id', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOBACKUP_HEADING_STATUS', 'a.status', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOBACKUP_HEADING_SIZE', 'a.total_size', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOBACKUP_HEADING_DATE', 'a.backupstart', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5">
|
||||
<?php echo Text::_('COM_MOKOBACKUP_HEADING_ACTIONS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($this->items as $i => $item) : ?>
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<?php echo HTMLHelper::_('grid.id', $i, $item->id); ?>
|
||||
</td>
|
||||
<td>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokobackup&view=backup&id=' . $item->id); ?>">
|
||||
<?php echo $this->escape($item->description); ?>
|
||||
</a>
|
||||
<?php if (!empty($item->checksum)) : ?>
|
||||
<br><small class="text-muted font-monospace"><?php echo Text::_('COM_MOKOBACKUP_FIELD_CHECKSUM'); ?>: <?php echo substr($item->checksum, 0, 16); ?>...</small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $this->escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
$statusClass = match ($item->status) {
|
||||
'complete' => 'badge bg-success',
|
||||
'running' => 'badge bg-info',
|
||||
'fail' => 'badge bg-danger',
|
||||
default => 'badge bg-secondary',
|
||||
};
|
||||
?>
|
||||
<span class="<?php echo $statusClass; ?>"><?php echo $this->escape($item->status); ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $this->escape($item->backup_type); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
if ($item->total_size > 0) {
|
||||
echo HTMLHelper::_('number.bytes', $item->total_size);
|
||||
} else {
|
||||
echo '—';
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('date', $item->backupstart, Text::_('DATE_FORMAT_LC4')); ?>
|
||||
</td>
|
||||
<td class="d-flex gap-1">
|
||||
<?php if ($item->status === 'complete' && $item->filesexist) : ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokobackup&task=backups.download&id=' . $item->id); ?>"
|
||||
class="btn btn-sm btn-outline-primary" title="<?php echo Text::_('COM_MOKOBACKUP_DOWNLOAD'); ?>">
|
||||
<span class="icon-download"></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mb-view-log"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
title="<?php echo Text::_('COM_MOKOBACKUP_VIEW_LOG'); ?>">
|
||||
<span class="icon-file-alt"></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo (int) $item->id; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php echo $this->pagination->getListFooter(); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<input type="hidden" name="task" value="">
|
||||
<input type="hidden" name="boxchecked" value="0">
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Stepped Backup Modal (for shared hosting) -->
|
||||
<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); ?>;
|
||||
|
||||
// Override the toolbar "Backup Now" button to use stepped backup
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Find the backup toolbar button and override it
|
||||
const toolbarBtn = document.querySelector('[onclick*="backups.start"], .button-download');
|
||||
if (toolbarBtn) {
|
||||
toolbarBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startSteppedBackup();
|
||||
return false;
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
// Init
|
||||
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);
|
||||
|
||||
// Run steps until done
|
||||
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;
|
||||
}
|
||||
|
||||
// Complete
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for toolbar button
|
||||
window.mokobackupStart = startSteppedBackup;
|
||||
|
||||
// View Log modal handler
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.mb-view-log');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
var recordId = btn.getAttribute('data-id');
|
||||
var modal = document.getElementById('mb-log-modal');
|
||||
var body = document.getElementById('mb-log-body');
|
||||
body.textContent = 'Loading...';
|
||||
modal.style.display = 'block';
|
||||
|
||||
var form = new URLSearchParams();
|
||||
form.append('task', 'ajax.viewLog');
|
||||
form.append('id', recordId);
|
||||
form.append(TOKEN_NAME, '1');
|
||||
|
||||
fetch(AJAX_URL, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
body.textContent = data.message || 'Error loading log';
|
||||
} else {
|
||||
body.textContent = data.log;
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
body.textContent = 'Error: ' + err.message;
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'mb-log-modal' || e.target.classList.contains('mb-log-close')) {
|
||||
document.getElementById('mb-log-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Log Viewer Modal -->
|
||||
<div id="mb-log-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:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:80vh;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;"><?php echo Text::_('COM_MOKOBACKUP_VIEW_LOG'); ?></h4>
|
||||
<button type="button" class="btn-close mb-log-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
|
||||
<pre id="mb-log-body" style="white-space:pre-wrap; word-break:break-word; font-size:0.85rem; margin:0; background:#f8f9fa; padding:1rem; border-radius:4px;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user