Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d74627720 | |||
| e5fb88e1a4 | |||
| df44518611 | |||
| 5e0683c0c7 | |||
| bbf3dcd449 | |||
| 139423cbe9 | |||
| 814d1b147c | |||
| 6dc7fdfa91 | |||
| 3de786cf71 |
@@ -5,7 +5,7 @@
|
||||
<display-name>Package - MokoJoomBackup</display-name>
|
||||
<org>MokoConsulting</org>
|
||||
<description>Full-site backup and restore for Joomla — database, files, and configuration</description>
|
||||
<version>01.05.00-dev</version>
|
||||
<version>01.07.01-dev</version>
|
||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
||||
</identity>
|
||||
<governance>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokoplatform.Automation
|
||||
# VERSION: 01.05.00
|
||||
# VERSION: 01.07.01
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+4
-60
@@ -1,6 +1,10 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [01.07.00] --- 2026-06-07
|
||||
|
||||
## [01.06.00] --- 2026-06-07
|
||||
|
||||
|
||||
## [01.05.00] --- 2026-06-07
|
||||
|
||||
@@ -26,63 +30,3 @@
|
||||
|
||||
|
||||
## [01.03.00] --- 2026-06-07
|
||||
|
||||
|
||||
## [01.02.00] --- 2026-06-07
|
||||
|
||||
### Added
|
||||
- Joomla-styled standalone installer (MokoRestore) with 7-step wizard, admin password reset, and client provisioning
|
||||
- Web cron trigger for shared hosting without crontab — URL-based backup with secret word, IP whitelist
|
||||
- Placeholder support for backup directories and archive filenames ([host], [date], [site_name], [profile_name], etc.)
|
||||
- FolderPicker JS placeholder resolution — resolves [site_name]/[host] when browsing, reverse-replaces on selection for portable profiles
|
||||
- Archive Name Format field on backup profiles with customizable filename templates
|
||||
- Interactive directory tree browser for exclude filters (replaces plain text input)
|
||||
- Backup log viewer modal in backup records list and inline in detail view
|
||||
- Clickable dashboard status tiles linking to backup records, detail views, and scheduled tasks
|
||||
- Table exclusion now supports separate Data and Structure checkboxes (backward compatible)
|
||||
- Tar.gz archive format support
|
||||
- User group notifications for backup events
|
||||
- Folder picker field with live server directory browsing
|
||||
- Default directory dashboard warning when backups are stored inside web root
|
||||
- Backup log files written alongside archives (.log)
|
||||
- Backup detail view with checksum, file path, DB size, and embedded log
|
||||
- Browser beforeunload warning during backup progress
|
||||
|
||||
### Changed
|
||||
- Renamed all extension elements from mokobackup to mokojoombackup (pkg, com, all plugins, DB tables, namespaces, language keys)
|
||||
- Renamed source directory from src/ to source/ per MokoStandards convention
|
||||
- Dashboard health check shows actual resolved backup directory path from profiles
|
||||
- Update site post-install notice links to filtered list view (avoids Joomla core bug)
|
||||
- License warning suppressed when download key is already configured
|
||||
- Download key preserved across package updates via preflight/postflight backup
|
||||
|
||||
### Fixed
|
||||
- Download ERR_INVALID_RESPONSE — flush output buffers before sending file headers
|
||||
- Backup directory path resolution for absolute paths outside web root
|
||||
- Schema migrations consolidated to version within extension range
|
||||
- PSR-4 class file naming (MokoBackup*.php → MokoJoomBackup*.php)
|
||||
- Nested package directories from rename flattened
|
||||
- INSERT IGNORE for default profile prevents duplicate key on update
|
||||
- ActionlogsHelper::getIp() replaced — method does not exist in Joomla 5
|
||||
- Console plugin namespace and quickicon translation keys
|
||||
- CLI exit codes and SQL schema defaults
|
||||
- Component Options page (added config.xml)
|
||||
- Placeholder-aware directory checks in FolderPicker and dashboard health
|
||||
|
||||
## 01.01 — 2026-06-04
|
||||
|
||||
### Added
|
||||
- Admin dashboard view as default landing page with status cards, quick actions, and system health checklist (#28)
|
||||
- Console plugin (plg_console_mokojoombackup) — CLI commands: run, list, profiles, restore, cleanup (#29)
|
||||
- Content plugin (plg_content_mokojoombackup) — auto-backup before extension install/update (#30)
|
||||
- Actionlog plugin (plg_actionlog_mokojoombackup) — logs backup and profile actions to User Action Logs (#31)
|
||||
- BackupEngine dispatches onMokoJoomBackupAfterRun event for plugin listeners
|
||||
- Update site notice on dashboard and post-install
|
||||
|
||||
### Changed
|
||||
- Renamed Kickstart to MokoRestore throughout
|
||||
|
||||
### Fixed
|
||||
- SQL update migration and error handling
|
||||
- Removed orphaned scriptfile from component manifest
|
||||
- Consolidated admin files into single files block
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoJoomBackup
|
||||
|
||||
<!-- VERSION: 01.05.00 -->
|
||||
<!-- VERSION: 01.07.01 -->
|
||||
|
||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokojoombackup</name>
|
||||
<version>01.05.00-rc</version>
|
||||
<version>01.07.01-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -18,6 +18,7 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\MVC\Controller\BaseController;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\Component\MokoJoomBackup\Administrator\Engine\SteppedBackupEngine;
|
||||
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class AjaxController extends BaseController
|
||||
{
|
||||
@@ -196,7 +197,7 @@ class AjaxController extends BaseController
|
||||
}
|
||||
|
||||
// Try to load log from file alongside the archive
|
||||
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
|
||||
$logPath = BackupDirectory::logPathFromArchive($record->absolute_path);
|
||||
$logContent = '';
|
||||
|
||||
if (is_file($logPath)) {
|
||||
@@ -239,17 +240,9 @@ class AjaxController extends BaseController
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve [DEFAULT_DIR] placeholder
|
||||
$defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
|
||||
$resolved = str_replace('[DEFAULT_DIR]', $defaultDir, $rawPath);
|
||||
$resolved = BackupDirectory::resolve($rawPath);
|
||||
|
||||
// Resolve relative paths from JPATH_ROOT
|
||||
if ($resolved !== '' && $resolved[0] !== '/' && !preg_match('#^[A-Za-z]:[/\\\\]#', $resolved)) {
|
||||
$resolved = JPATH_ROOT . '/' . $resolved;
|
||||
}
|
||||
|
||||
// Skip check if unresolved placeholders remain
|
||||
if (preg_match('/\[.+\]/', $resolved)) {
|
||||
if (BackupDirectory::hasPlaceholders($resolved)) {
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'exists' => null,
|
||||
|
||||
@@ -30,6 +30,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class AkeebaImporter
|
||||
{
|
||||
@@ -484,7 +485,7 @@ class AkeebaImporter
|
||||
$dir = $config['akeeba.basic.output_directory'] ?? '';
|
||||
|
||||
if (empty($dir) || $dir === '[DEFAULT_OUTPUT]') {
|
||||
return 'administrator/components/com_mokojoombackup/backups';
|
||||
return BackupDirectory::DEFAULT_RELATIVE;
|
||||
}
|
||||
|
||||
// Convert absolute path to relative
|
||||
@@ -492,7 +493,7 @@ class AkeebaImporter
|
||||
$dir = ltrim(substr($dir, strlen(JPATH_ROOT)), '/\\');
|
||||
}
|
||||
|
||||
return $dir ?: 'administrator/components/com_mokojoombackup/backups';
|
||||
return $dir ?: BackupDirectory::DEFAULT_RELATIVE;
|
||||
}
|
||||
|
||||
private function mapRemoteStorage(array $config): string
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class BackupEngine
|
||||
@@ -56,24 +57,20 @@ class BackupEngine
|
||||
}
|
||||
|
||||
// Read settings directly from profile columns
|
||||
$excludeDirs = $this->parseNewlineList($profile->exclude_dirs ?? '');
|
||||
$excludeFiles = $this->parseNewlineList($profile->exclude_files ?? '');
|
||||
$excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
|
||||
$excludeDirs = BackupDirectory::parseNewlineList($profile->exclude_dirs ?? '');
|
||||
$excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? '');
|
||||
$excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
||||
|
||||
// Resolve placeholders in directory and filename
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
|
||||
$configuredDir = $profile->backup_dir ?: '[DEFAULT_DIR]';
|
||||
$this->backupDir = $this->resolveBackupDir($resolver->resolve($configuredDir));
|
||||
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||
$this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
|
||||
|
||||
if (!is_dir($this->backupDir)) {
|
||||
if (!mkdir($this->backupDir, 0755, true)) {
|
||||
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
|
||||
}
|
||||
if (!BackupDirectory::ensureReady($this->backupDir)) {
|
||||
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
|
||||
}
|
||||
|
||||
$this->protectBackupDir($this->backupDir);
|
||||
|
||||
// Create backup record
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
@@ -474,21 +471,6 @@ class BackupEngine
|
||||
$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 onMokoJoomBackupAfterRun event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
@@ -512,38 +494,6 @@ class BackupEngine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 protectBackupDir(string $dir): void
|
||||
{
|
||||
$htaccess = $dir . '/.htaccess';
|
||||
|
||||
if (!is_file($htaccess)) {
|
||||
if (@file_put_contents($htaccess, "# Apache 2.4+\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n") === false) {
|
||||
error_log('MokoJoomBackup: Could not create .htaccess in backup directory: ' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
$index = $dir . '/index.html';
|
||||
|
||||
if (!is_file($index)) {
|
||||
if (@file_put_contents($index, '<!DOCTYPE html><title></title>') === false) {
|
||||
error_log('MokoJoomBackup: Could not create index.html in backup directory: ' . $dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class PlaceholderResolver
|
||||
{
|
||||
@@ -38,7 +39,7 @@ class PlaceholderResolver
|
||||
'[site_name]' => 'Joomla site name (sanitized)',
|
||||
'[type]' => 'Backup type (full, database, files, differential)',
|
||||
'[random]' => 'Random 6-character hex string',
|
||||
'[DEFAULT_DIR]' => 'Default backup directory (administrator/components/com_mokojoombackup/backups)',
|
||||
'[DEFAULT_DIR]' => 'Default backup directory',
|
||||
];
|
||||
|
||||
private array $replacements;
|
||||
@@ -75,7 +76,7 @@ class PlaceholderResolver
|
||||
'[site_name]' => $this->sanitize($siteName ?: 'joomla'),
|
||||
'[type]' => $profile->backup_type ?? 'full',
|
||||
'[random]' => bin2hex(random_bytes(3)),
|
||||
'[DEFAULT_DIR]' => JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups',
|
||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class SteppedBackupEngine
|
||||
{
|
||||
@@ -52,26 +53,22 @@ class SteppedBackupEngine
|
||||
$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 ?: '[DEFAULT_DIR]';
|
||||
$session->excludeDirs = BackupDirectory::parseNewlineList($profile->exclude_dirs ?? '');
|
||||
$session->excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? '');
|
||||
$session->excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
|
||||
$session->backupDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
|
||||
$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));
|
||||
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
if (!mkdir($backupDir, 0755, true)) {
|
||||
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
|
||||
}
|
||||
if (!BackupDirectory::ensureReady($backupDir)) {
|
||||
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
|
||||
}
|
||||
|
||||
$this->protectBackupDir($backupDir);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
@@ -422,7 +419,7 @@ class SteppedBackupEngine
|
||||
$logContent = implode("\n", $session->log);
|
||||
|
||||
// Write log file alongside the archive
|
||||
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $session->archivePath);
|
||||
$logPath = BackupDirectory::logPathFromArchive($session->archivePath);
|
||||
if (@file_put_contents($logPath, $logContent) === false) {
|
||||
error_log('MokoJoomBackup: Could not write log file: ' . $logPath);
|
||||
}
|
||||
@@ -554,47 +551,4 @@ class SteppedBackupEngine
|
||||
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 protectBackupDir(string $dir): void
|
||||
{
|
||||
$htaccess = $dir . '/.htaccess';
|
||||
|
||||
if (!is_file($htaccess)) {
|
||||
if (@file_put_contents($htaccess, "# Apache 2.4+\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n") === false) {
|
||||
error_log('MokoJoomBackup: Could not create .htaccess in backup directory: ' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
$index = $dir . '/index.html';
|
||||
|
||||
if (!is_file($index)) {
|
||||
if (@file_put_contents($index, '<!DOCTYPE html><title></title>') === false) {
|
||||
error_log('MokoJoomBackup: Could not create index.html in backup directory: ' . $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 !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class FolderPickerField extends FormField
|
||||
{
|
||||
@@ -49,7 +50,7 @@ class FolderPickerField extends FormField
|
||||
$sanitizedSiteName = preg_replace('/[^a-zA-Z0-9._-]/', '', str_replace(' ', '-', trim($siteName)));
|
||||
|
||||
$placeholders = [
|
||||
'[DEFAULT_DIR]' => JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups',
|
||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||
'[host]' => $hostname,
|
||||
'[site_name]' => $sanitizedSiteName ?: 'joomla',
|
||||
'[profile_id]' => '1',
|
||||
|
||||
@@ -14,6 +14,7 @@ defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
|
||||
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
|
||||
|
||||
class DashboardModel extends BaseDatabaseModel
|
||||
{
|
||||
@@ -122,11 +123,9 @@ class DashboardModel extends BaseDatabaseModel
|
||||
'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+',
|
||||
];
|
||||
|
||||
// Backup directory writable — check the default path
|
||||
$defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
|
||||
$backupDir = $defaultDir;
|
||||
// Backup directory writable — check the first published profile's dir
|
||||
$backupDir = BackupDirectory::getDefaultAbsolute();
|
||||
|
||||
// If profiles use a custom directory, check that instead
|
||||
$db2 = $this->getDatabase();
|
||||
$qDir = $db2->getQuery(true)
|
||||
->select($db2->quoteName('backup_dir'))
|
||||
@@ -138,16 +137,10 @@ class DashboardModel extends BaseDatabaseModel
|
||||
$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;
|
||||
}
|
||||
$backupDir = BackupDirectory::resolve($profileDir);
|
||||
}
|
||||
|
||||
// Skip filesystem check if path contains placeholders (resolved at backup time)
|
||||
if (preg_match('/\[.+\]/', $backupDir)) {
|
||||
if (BackupDirectory::hasPlaceholders($backupDir)) {
|
||||
$checks[] = (object) [
|
||||
'label' => 'Backup Directory',
|
||||
'status' => true,
|
||||
@@ -182,14 +175,12 @@ class DashboardModel extends BaseDatabaseModel
|
||||
public function isUsingDefaultBackupDir(): bool
|
||||
{
|
||||
$db = $this->getDatabase();
|
||||
$default = 'administrator/components/com_mokojoombackup/backups';
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoombackup_profiles'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote($default)
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('[DEFAULT_DIR]')
|
||||
->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote(BackupDirectory::DEFAULT_RELATIVE)
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote(BackupDirectory::PLACEHOLDER)
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
|
||||
$db->setQuery($query);
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Table;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Table\Table;
|
||||
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
|
||||
use Joomla\Database\DatabaseDriver;
|
||||
|
||||
class ProfileTable extends Table
|
||||
@@ -35,49 +36,17 @@ class ProfileTable extends Table
|
||||
|
||||
private function protectWebAccessibleDir(string $dir): void
|
||||
{
|
||||
// Resolve [DEFAULT_DIR] placeholder
|
||||
$defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
|
||||
$resolved = str_replace('[DEFAULT_DIR]', $defaultDir, $dir);
|
||||
$resolved = BackupDirectory::resolve($dir);
|
||||
|
||||
// Resolve relative paths from JPATH_ROOT
|
||||
if ($resolved !== '' && $resolved[0] !== '/' && !preg_match('#^[A-Za-z]:[/\\\\]#', $resolved)) {
|
||||
$resolved = JPATH_ROOT . '/' . $resolved;
|
||||
}
|
||||
|
||||
// Skip if unresolved placeholders remain
|
||||
if (preg_match('/\[.+\]/', $resolved)) {
|
||||
if (BackupDirectory::hasPlaceholders($resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only protect directories under the web root
|
||||
$jRoot = realpath(JPATH_ROOT) ?: JPATH_ROOT;
|
||||
$realDir = realpath($resolved) ?: $resolved;
|
||||
|
||||
if (strpos($realDir, $jRoot) !== 0) {
|
||||
if (!BackupDirectory::isWebAccessible($resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($resolved)) {
|
||||
@mkdir($resolved, 0755, true);
|
||||
}
|
||||
|
||||
if (is_dir($resolved)) {
|
||||
$htaccess = $resolved . '/.htaccess';
|
||||
|
||||
if (!is_file($htaccess)) {
|
||||
if (@file_put_contents($htaccess, "# Apache 2.4+\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n") === false) {
|
||||
error_log('MokoJoomBackup: Could not create .htaccess in: ' . $resolved);
|
||||
}
|
||||
}
|
||||
|
||||
$index = $resolved . '/index.html';
|
||||
|
||||
if (!is_file($index)) {
|
||||
if (@file_put_contents($index, '<!DOCTYPE html><title></title>') === false) {
|
||||
error_log('MokoJoomBackup: Could not create index.html in: ' . $resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
BackupDirectory::ensureReady($resolved);
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoJoomBackup
|
||||
* @subpackage com_mokojoombackup
|
||||
* @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\MokoJoomBackup\Administrator\Utility;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
class BackupDirectory
|
||||
{
|
||||
public const DEFAULT_RELATIVE = 'administrator/components/com_mokojoombackup/backups';
|
||||
|
||||
public const PLACEHOLDER = '[DEFAULT_DIR]';
|
||||
|
||||
private const HTACCESS_CONTENT = <<<'HTACCESS'
|
||||
# Apache 2.4+
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
# Apache 2.2
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
HTACCESS;
|
||||
|
||||
private const INDEX_CONTENT = '<!DOCTYPE html><title></title>';
|
||||
|
||||
/**
|
||||
* Get the absolute default backup directory path.
|
||||
*/
|
||||
public static function getDefaultAbsolute(): string
|
||||
{
|
||||
return JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a backup directory path. Replaces [DEFAULT_DIR] placeholder,
|
||||
* then resolves relative paths from JPATH_ROOT.
|
||||
*
|
||||
* @param string $dir Raw directory value from profile
|
||||
*
|
||||
* @return string Absolute path (may still contain other placeholders)
|
||||
*/
|
||||
public static function resolve(string $dir): string
|
||||
{
|
||||
if ($dir === '' || $dir === self::PLACEHOLDER) {
|
||||
$dir = self::getDefaultAbsolute();
|
||||
} else {
|
||||
$dir = str_replace(self::PLACEHOLDER, self::getDefaultAbsolute(), $dir);
|
||||
}
|
||||
|
||||
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
|
||||
return rtrim($dir, '/\\');
|
||||
}
|
||||
|
||||
return JPATH_ROOT . '/' . $dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a resolved path still contains unresolved placeholders.
|
||||
*/
|
||||
public static function hasPlaceholders(string $path): bool
|
||||
{
|
||||
return (bool) preg_match('/\[.+\]/', $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a resolved absolute path is inside the web root.
|
||||
*/
|
||||
public static function isWebAccessible(string $absolutePath): bool
|
||||
{
|
||||
$jRoot = realpath(JPATH_ROOT) ?: JPATH_ROOT;
|
||||
$realDir = realpath($absolutePath) ?: $absolutePath;
|
||||
|
||||
return strpos($realDir, $jRoot) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create .htaccess and index.html protection files in a directory.
|
||||
* Only creates files if they don't already exist.
|
||||
*/
|
||||
public static function protect(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$htaccess = $dir . '/.htaccess';
|
||||
|
||||
if (!is_file($htaccess)) {
|
||||
if (@file_put_contents($htaccess, self::HTACCESS_CONTENT . "\n") === false) {
|
||||
error_log('MokoJoomBackup: Could not create .htaccess in: ' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
$index = $dir . '/index.html';
|
||||
|
||||
if (!is_file($index)) {
|
||||
if (@file_put_contents($index, self::INDEX_CONTENT) === false) {
|
||||
error_log('MokoJoomBackup: Could not create index.html in: ' . $dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the backup directory exists, create it if needed,
|
||||
* and apply web protection if it's inside the web root.
|
||||
*
|
||||
* @return bool True if directory exists and is usable
|
||||
*/
|
||||
public static function ensureReady(string $dir): bool
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
if (!@mkdir($dir, 0755, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
self::protect($dir);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a newline-separated text field into an array of trimmed, non-empty strings.
|
||||
*/
|
||||
public static 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 !== ''
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the log file path from an archive path.
|
||||
*/
|
||||
public static function logPathFromArchive(string $archivePath): string
|
||||
{
|
||||
return preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>plg_actionlog_mokojoombackup</name>
|
||||
<version>01.05.00-rc</version>
|
||||
<version>01.07.01-dev</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>plg_console_mokojoombackup</name>
|
||||
<version>01.05.00-rc</version>
|
||||
<version>01.07.01-dev</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>plg_content_mokojoombackup</name>
|
||||
<version>01.05.00-rc</version>
|
||||
<version>01.07.01-dev</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="quickicon" method="upgrade">
|
||||
<name>plg_quickicon_mokojoombackup</name>
|
||||
<version>01.05.00-rc</version>
|
||||
<version>01.07.01-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>plg_system_mokojoombackup</name>
|
||||
<version>01.05.00-rc</version>
|
||||
<version>01.07.01-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>plg_task_mokojoombackup</name>
|
||||
<version>01.05.00-rc</version>
|
||||
<version>01.07.01-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>plg_webservices_mokojoombackup</name>
|
||||
<version>01.05.00-rc</version>
|
||||
<version>01.07.01-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoJoomBackup</name>
|
||||
<packagename>mokojoombackup</packagename>
|
||||
<version>01.05.00-rc</version>
|
||||
<version>01.07.01-dev</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+129
-8
@@ -191,24 +191,145 @@ class Pkg_MokoJoomBackupInstallerScript
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
|
||||
// Create default backup directory
|
||||
// Create and protect default backup directory
|
||||
$backupDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
mkdir($backupDir, 0755, true);
|
||||
|
||||
// Protect backup directory with .htaccess
|
||||
file_put_contents($backupDir . '/.htaccess', "# Apache 2.4+\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n");
|
||||
file_put_contents($backupDir . '/index.html', '<!DOCTYPE html><title></title>');
|
||||
}
|
||||
|
||||
if (is_dir($backupDir)) {
|
||||
$htaccess = $backupDir . '/.htaccess';
|
||||
|
||||
if (!is_file($htaccess)) {
|
||||
file_put_contents($htaccess, "# Apache 2.4+\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\n");
|
||||
}
|
||||
|
||||
$index = $backupDir . '/index.html';
|
||||
|
||||
if (!is_file($index)) {
|
||||
file_put_contents($index, '<!DOCTYPE html><title></title>');
|
||||
}
|
||||
}
|
||||
|
||||
// Create default scheduled task — every 30 days, profile 1
|
||||
$this->createDefaultScheduledTask();
|
||||
}
|
||||
|
||||
if ($type === 'uninstall') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades)
|
||||
$this->syncMenuIcons();
|
||||
|
||||
// Warn if no license key configured (skip on uninstall)
|
||||
if ($type !== 'uninstall') {
|
||||
$this->warnMissingLicenseKey();
|
||||
// Warn if no license key configured
|
||||
$this->warnMissingLicenseKey();
|
||||
|
||||
// Warn if any profile still uses the default backup directory
|
||||
$this->warnDefaultBackupDir();
|
||||
|
||||
// Remind user to review backup profile settings
|
||||
if ($type === 'install') {
|
||||
$profileUrl = Route::_('index.php?option=com_mokojoombackup&view=profiles');
|
||||
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
'<strong>Review Your Backup Settings</strong> — '
|
||||
. 'A default backup profile has been created. Review the profile settings to configure '
|
||||
. 'backup type, schedule, storage location, and notifications. '
|
||||
. '<a href="' . $profileUrl . '" class="btn btn-sm btn-primary ms-2">Review Profiles</a>',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function warnDefaultBackupDir(): void
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokojoombackup_profiles'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote('administrator/components/com_mokojoombackup/backups')
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('[DEFAULT_DIR]')
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
|
||||
$db->setQuery($query);
|
||||
|
||||
if ((int) $db->loadResult() > 0) {
|
||||
$profileUrl = Route::_('index.php?option=com_mokojoombackup&view=profiles');
|
||||
|
||||
Factory::getApplication()->enqueueMessage(
|
||||
'<strong>Backup Directory Warning</strong> — '
|
||||
. 'One or more profiles store backups in the default directory inside the web root. '
|
||||
. 'For better security, configure a backup directory outside the web root. '
|
||||
. '<a href="' . $profileUrl . '" class="btn btn-sm btn-warning ms-2">Edit Profiles</a>',
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoJoomBackup: warnDefaultBackupDir() failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function createDefaultScheduledTask(): void
|
||||
{
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
// Check if a MokoJoomBackup task already exists
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__scheduler_tasks'))
|
||||
->where($db->quoteName('type') . ' = ' . $db->quote('mokojoombackup.run_profile'));
|
||||
$db->setQuery($query);
|
||||
|
||||
if ((int) $db->loadResult() > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$task = (object) [
|
||||
'title' => 'MokoJoomBackup — Monthly Full Backup',
|
||||
'type' => 'mokojoombackup.run_profile',
|
||||
'execution_rules' => json_encode([
|
||||
'rule-type' => 'interval-days',
|
||||
'interval-days' => '30',
|
||||
'exec-day' => '1',
|
||||
'exec-time' => '03:00:00',
|
||||
]),
|
||||
'cron_rules' => json_encode([
|
||||
'type' => 'interval',
|
||||
'exp' => 'P30D',
|
||||
]),
|
||||
'state' => 1,
|
||||
'params' => json_encode([
|
||||
'profile_id' => 1,
|
||||
'individual_log' => true,
|
||||
'log_file' => '',
|
||||
'notifications' => [
|
||||
'success_mail' => '0',
|
||||
'failure_mail' => '1',
|
||||
'notification_failure_groups' => ['8'],
|
||||
'fatal_failure_mail' => '1',
|
||||
'notification_fatal_groups' => ['8'],
|
||||
'orphan_mail' => '0',
|
||||
],
|
||||
]),
|
||||
'priority' => 0,
|
||||
'ordering' => 0,
|
||||
'cli_exclusive' => 0,
|
||||
'note' => '',
|
||||
'created' => $now,
|
||||
'created_by' => Factory::getApplication()->getIdentity()->id ?? 0,
|
||||
'next_execution' => date('Y-m-d 03:00:00', strtotime('+1 day')),
|
||||
];
|
||||
|
||||
$db->insertObject('#__scheduler_tasks', $task);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoJoomBackup: createDefaultScheduledTask() failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user