Release v01.05.00 — dashboard menu, [DEFAULT_DIR], live validation, security hardening #42
@@ -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.04.00-dev</version>
|
||||
<version>01.05.00-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.04.00
|
||||
# VERSION: 01.05.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+20
-36
@@ -1,8 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [01.05.00] --- 2026-06-07
|
||||
|
||||
### Added
|
||||
- Dashboard submenu entry as default landing page with `class:home` icon
|
||||
- `[DEFAULT_DIR]` placeholder for portable backup directory configuration — resolves to `administrator/components/com_mokojoombackup/backups` at runtime
|
||||
- Live AJAX directory validation on backup_dir field — checks existence, writability, and placeholder resolution as user types (debounced 400ms)
|
||||
- `checkDir` AJAX endpoint for real-time directory permission checking
|
||||
- Web-accessible warning badge on backup download buttons when archive is inside web root
|
||||
- Inline security warning in FolderPicker when default directory is selected
|
||||
- Auto `.htaccess` and `index.html` protection for web-accessible backup directories on profile save and at backup time
|
||||
- Font Awesome 6 submenu icons via CSS injection in `MokoJoomBackupComponent::boot()`
|
||||
- `syncMenuIcons()` installer postflight — syncs icon classes to `#__menu` on install and update
|
||||
- `encryptionPassword` property on `SteppedSession` for upcoming stepped backup encryption support
|
||||
|
||||
### Changed
|
||||
- Profile `backup_dir` default changed from literal path to `[DEFAULT_DIR]` placeholder
|
||||
- Backup engine fallback directory changed from hardcoded path to `[DEFAULT_DIR]`
|
||||
- `isUsingDefaultBackupDir()` now matches `[DEFAULT_DIR]` placeholder in addition to literal path and empty values
|
||||
- Dashboard submenu language key added to `.sys.ini` files (en-GB, en-US)
|
||||
|
||||
## [01.04.00] --- 2026-06-07
|
||||
|
||||
|
||||
@@ -67,38 +86,3 @@
|
||||
- SQL update migration and error handling
|
||||
- Removed orphaned scriptfile from component manifest
|
||||
- Consolidated admin files into single files block
|
||||
|
||||
## 01.00 — 2026-06-02
|
||||
|
||||
### Added
|
||||
- Initial package structure with component, system plugin, task plugin, and webservices plugin
|
||||
- Joomla Scheduled Tasks integration (plg_task_mokojoombackup) — create multiple tasks, each running a different backup profile on its own schedule
|
||||
- Individual form fields for all profile settings (no raw JSON)
|
||||
- FTP/FTPS uploader with recursive directory creation, passive mode, SSL, and size verification
|
||||
- Google Drive uploader using OAuth2 refresh tokens and resumable upload API
|
||||
- S3-compatible remote storage: AWS S3, Wasabi, Backblaze B2, MinIO (#16)
|
||||
- RemoteUploaderInterface for pluggable storage backends
|
||||
- Remote upload integrated into BackupEngine with option to delete local copy after upload
|
||||
- Restore engine with file restoration and database import
|
||||
- MokoRestore standalone restore script — self-contained site restoration without Joomla
|
||||
- "Include Restore Script" toggle per profile
|
||||
- FileRestorer with protected file handling (preserves configuration.php, .htaccess)
|
||||
- DatabaseImporter with streaming line-by-line SQL execution and error tolerance
|
||||
- Admin dashboard quickicon widget — backup status at a glance with warnings (#18)
|
||||
- Differential backups — only back up files changed since last full backup (#19)
|
||||
- DifferentialScanner with file manifests stored in backup records
|
||||
- JPA archive format import for Akeeba Backup migration (#20)
|
||||
- AES-256 archive encryption with per-profile password (#17)
|
||||
- SHA-256 checksum verification for backup integrity (#15)
|
||||
- Email notifications on backup success/failure via Joomla mailer (#14)
|
||||
- Akeeba Backup Pro importer — profiles, filters, remote storage, and backup history
|
||||
- Auto-disables Akeeba plugins and scheduled tasks after successful import
|
||||
- AJAX step-based backup engine for shared hosting (overcomes max_execution_time)
|
||||
- Progress bar modal in admin UI with real-time phase/percentage updates
|
||||
- Per-profile archive settings: format, compression level, split size, backup directory
|
||||
- Backup engine with database dumper, file scanner, and ZIP archive builder
|
||||
- Backup profiles with independent configurations
|
||||
- Backup record management (list, download, delete)
|
||||
- CLI script for cron/scheduled backups
|
||||
- REST API compatible with MokoJoomBackup MCP server
|
||||
- System plugin for automatic backup cleanup with configurable retention
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoJoomBackup
|
||||
|
||||
<!-- VERSION: 01.04.00 -->
|
||||
<!-- VERSION: 01.05.00 -->
|
||||
|
||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
type="FolderPicker"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC"
|
||||
default="administrator/components/com_mokojoombackup/backups"
|
||||
default="[DEFAULT_DIR]"
|
||||
addfieldprefix="Joomla\Component\MokoJoomBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
|
||||
@@ -269,6 +269,8 @@ COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whos
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
|
||||
COM_MOKOJOOMBACKUP_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_MOKOJOOMBACKUP_WEB_ACCESSIBLE_WARNING="This backup is stored inside the web root and may be directly downloadable if .htaccess is not supported."
|
||||
|
||||
; Errors
|
||||
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
||||
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
|
||||
COM_MOKOJOOMBACKUP="MokoJoomBackup"
|
||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles"
|
||||
|
||||
@@ -53,6 +53,7 @@ COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND="Directory not found"
|
||||
COM_MOKOJOOMBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
|
||||
COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
|
||||
COM_MOKOJOOMBACKUP_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_MOKOJOOMBACKUP_WEB_ACCESSIBLE_WARNING="This backup is stored inside the web root and may be directly downloadable if .htaccess is not supported."
|
||||
COM_MOKOJOOMBACKUP_FOLDER_EXISTS="Directory exists"
|
||||
COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND="Directory not found"
|
||||
COM_MOKOJOOMBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
|
||||
COM_MOKOJOOMBACKUP="MokoJoomBackup"
|
||||
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
|
||||
COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>com_mokojoombackup</name>
|
||||
<version>01.04.00</version>
|
||||
<version>01.05.00-rc</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -40,6 +40,7 @@
|
||||
<administration>
|
||||
<menu img="class:archive">COM_MOKOJOOMBACKUP</menu>
|
||||
<submenu>
|
||||
<menu link="option=com_mokojoombackup&view=dashboard" img="class:home">COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD</menu>
|
||||
<menu link="option=com_mokojoombackup&view=backups" img="class:database">COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS</menu>
|
||||
<menu link="option=com_mokojoombackup&view=profiles" img="class:cog">COM_MOKOJOOMBACKUP_SUBMENU_PROFILES</menu>
|
||||
</submenu>
|
||||
|
||||
@@ -109,6 +109,7 @@ class AjaxController extends BaseController
|
||||
// that could contain a backup folder (e.g., /home/user/backups)
|
||||
$dirs = [];
|
||||
$handle = @opendir($path);
|
||||
$warning = null;
|
||||
|
||||
if ($handle) {
|
||||
while (($entry = readdir($handle)) !== false) {
|
||||
@@ -127,18 +128,37 @@ class AjaxController extends BaseController
|
||||
}
|
||||
|
||||
closedir($handle);
|
||||
} else {
|
||||
$warning = 'Cannot read directory contents (check permissions)';
|
||||
}
|
||||
|
||||
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||
|
||||
$parent = dirname($path);
|
||||
|
||||
$this->sendJson([
|
||||
// Ensure parent is still within allowed boundaries
|
||||
$parentAllowed = false;
|
||||
|
||||
if ($parent !== $path) {
|
||||
if ($jRoot !== false && strpos($parent, $jRoot) === 0) {
|
||||
$parentAllowed = true;
|
||||
} elseif ($homeDir !== '' && strpos($parent, $homeDir) === 0) {
|
||||
$parentAllowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$response = [
|
||||
'error' => false,
|
||||
'current' => $path,
|
||||
'parent' => ($parent !== $path) ? $parent : null,
|
||||
'parent' => $parentAllowed ? $parent : null,
|
||||
'dirs' => $dirs,
|
||||
]);
|
||||
];
|
||||
|
||||
if ($warning !== null) {
|
||||
$response['warning'] = $warning;
|
||||
}
|
||||
|
||||
$this->sendJson($response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,7 +185,7 @@ class AjaxController extends BaseController
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['absolute_path', 'log']))
|
||||
->from($db->quoteName('#__mokojoombackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . $id);
|
||||
->where($db->quoteName('id') . ' = ' . (int) $id);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
|
||||
@@ -193,6 +213,66 @@ class AjaxController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check directory existence, writability and permissions.
|
||||
* POST: task=ajax.checkDir&path=/some/path
|
||||
*/
|
||||
public function checkDir(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokojoombackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$rawPath = trim($this->input->getString('path', ''));
|
||||
|
||||
if ($rawPath === '') {
|
||||
$this->sendJson(['error' => true, 'message' => 'No path provided']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve [DEFAULT_DIR] placeholder
|
||||
$defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
|
||||
$resolved = str_replace('[DEFAULT_DIR]', $defaultDir, $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)) {
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'exists' => null,
|
||||
'writable' => null,
|
||||
'resolved' => $resolved,
|
||||
'placeholder' => true,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = is_dir($resolved);
|
||||
$writable = $exists && is_writable($resolved);
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'exists' => $exists,
|
||||
'writable' => $writable,
|
||||
'resolved' => $resolved,
|
||||
'placeholder' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close the application.
|
||||
*/
|
||||
|
||||
@@ -63,7 +63,7 @@ class BackupEngine
|
||||
// Resolve placeholders in directory and filename
|
||||
$resolver = new PlaceholderResolver($profile);
|
||||
|
||||
$configuredDir = $profile->backup_dir ?: 'administrator/components/com_mokojoombackup/backups';
|
||||
$configuredDir = $profile->backup_dir ?: '[DEFAULT_DIR]';
|
||||
$this->backupDir = $this->resolveBackupDir($resolver->resolve($configuredDir));
|
||||
|
||||
if (!is_dir($this->backupDir)) {
|
||||
@@ -72,6 +72,8 @@ class BackupEngine
|
||||
}
|
||||
}
|
||||
|
||||
$this->protectBackupDir($this->backupDir);
|
||||
|
||||
// Create backup record
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
@@ -523,6 +525,25 @@ class BackupEngine
|
||||
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;
|
||||
|
||||
@@ -38,6 +38,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)',
|
||||
];
|
||||
|
||||
private array $replacements;
|
||||
@@ -74,6 +75,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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class SteppedBackupEngine
|
||||
$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_mokojoombackup/backups';
|
||||
$session->backupDir = $profile->backup_dir ?: '[DEFAULT_DIR]';
|
||||
$session->remoteStorage = $profile->remote_storage ?? 'none';
|
||||
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
||||
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
|
||||
@@ -70,6 +70,8 @@ class SteppedBackupEngine
|
||||
}
|
||||
}
|
||||
|
||||
$this->protectBackupDir($backupDir);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
@@ -315,8 +317,8 @@ class SteppedBackupEngine
|
||||
$zip->close();
|
||||
|
||||
// Clean up temp SQL file
|
||||
if (is_file($sqlFile)) {
|
||||
@unlink($sqlFile);
|
||||
if (is_file($sqlFile) && !@unlink($sqlFile)) {
|
||||
error_log('MokoJoomBackup: Could not delete temp SQL file: ' . $sqlFile);
|
||||
}
|
||||
|
||||
$totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0;
|
||||
@@ -565,6 +567,25 @@ class SteppedBackupEngine
|
||||
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)) {
|
||||
|
||||
@@ -53,6 +53,7 @@ class SteppedSession
|
||||
public string $remoteStorage = 'none';
|
||||
public bool $includeMokoRestore = false;
|
||||
public bool $remoteKeepLocal = true;
|
||||
public string $encryptionPassword = '';
|
||||
|
||||
// Progress
|
||||
public int $totalSteps = 0;
|
||||
|
||||
@@ -13,7 +13,33 @@ namespace Joomla\Component\MokoJoomBackup\Administrator\Extension;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Extension\MVCComponent;
|
||||
use Joomla\CMS\Factory;
|
||||
|
||||
class MokoJoomBackupComponent extends MVCComponent
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
|
||||
if (!$app->isClient('administrator')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wa = $app->getDocument()->getWebAssetManager();
|
||||
$wa->addInlineStyle(
|
||||
'#menu a[href*="com_mokojoombackup"][href*="view=dashboard"] .sidebar-item-title::before,'
|
||||
. ' #menu a[href*="com_mokojoombackup"][href*="view=backups"] .sidebar-item-title::before,'
|
||||
. ' #menu a[href*="com_mokojoombackup"][href*="view=profiles"] .sidebar-item-title::before'
|
||||
. ' { font-family: "Font Awesome 6 Free"; font-weight: 900; margin-right: .5em; }'
|
||||
. ' #menu a[href*="com_mokojoombackup"][href*="view=dashboard"] .sidebar-item-title::before { content: "\f015"; }'
|
||||
. ' #menu a[href*="com_mokojoombackup"][href*="view=backups"] .sidebar-item-title::before { content: "\f1c0"; }'
|
||||
. ' #menu a[href*="com_mokojoombackup"][href*="view=profiles"] .sidebar-item-title::before { content: "\f013"; }'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoJoomBackup: boot() CSS injection failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,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',
|
||||
'[host]' => $hostname,
|
||||
'[site_name]' => $sanitizedSiteName ?: 'joomla',
|
||||
'[profile_id]' => '1',
|
||||
@@ -88,7 +89,7 @@ class FolderPickerField extends FormField
|
||||
<div class="input-group">
|
||||
<input type="text" name="{$name}" id="{$id}" value="{$value}"
|
||||
class="form-control" maxlength="512"
|
||||
placeholder="/home/user/backups/[host] or administrator/components/com_mokojoombackup/backups" />
|
||||
placeholder="[DEFAULT_DIR] or /home/user/backups/[host]" />
|
||||
<button type="button" class="btn btn-outline-secondary" id="{$id}_btn">
|
||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||
Browse
|
||||
@@ -100,6 +101,10 @@ class FolderPickerField extends FormField
|
||||
{$statusDetail}
|
||||
</small>
|
||||
</div>
|
||||
<div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
|
||||
</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>
|
||||
@@ -132,6 +137,85 @@ class FolderPickerField extends FormField
|
||||
return path;
|
||||
}
|
||||
|
||||
var statusDiv = document.getElementById(fieldId + '_status');
|
||||
var checkTimer = null;
|
||||
|
||||
function setStatus(cssClass, iconClass, message, codePath) {
|
||||
while (statusDiv.firstChild) statusDiv.removeChild(statusDiv.firstChild);
|
||||
var small = document.createElement('small');
|
||||
small.className = cssClass;
|
||||
var icon = document.createElement('span');
|
||||
icon.className = iconClass;
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
small.appendChild(icon);
|
||||
small.appendChild(document.createTextNode(' ' + message));
|
||||
if (codePath) {
|
||||
small.appendChild(document.createTextNode(': '));
|
||||
var code = document.createElement('code');
|
||||
code.textContent = codePath;
|
||||
small.appendChild(code);
|
||||
}
|
||||
statusDiv.appendChild(small);
|
||||
}
|
||||
|
||||
function setDefaultDirWarning() {
|
||||
var warn = document.getElementById(fieldId + '_defaultwarn');
|
||||
var val = input.value.trim();
|
||||
var isDefault = (!val || val === '[DEFAULT_DIR]' || val === 'administrator/components/com_mokojoombackup/backups');
|
||||
if (warn) warn.style.display = isDefault ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function checkDirPermissions() {
|
||||
var val = input.value.trim();
|
||||
if (!val) return;
|
||||
|
||||
setStatus('text-muted', 'icon-spinner icon-spin', 'Checking...', null);
|
||||
setDefaultDirWarning();
|
||||
|
||||
var form = new URLSearchParams();
|
||||
form.append('task', 'ajax.checkDir');
|
||||
form.append('path', val);
|
||||
var tokenName = Joomla.getOptions('csrf.token') || '';
|
||||
if (tokenName) form.append(tokenName, '1');
|
||||
|
||||
fetch('index.php?option=com_mokojoombackup&format=json', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')'); return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
setStatus('text-danger', 'icon-unpublish', data.message || 'Error', null);
|
||||
return;
|
||||
}
|
||||
if (data.placeholder) {
|
||||
setStatus('text-info', 'icon-info-circle', 'Uses placeholders (resolved at backup time)', data.resolved);
|
||||
return;
|
||||
}
|
||||
if (data.writable) {
|
||||
setStatus('text-success', 'icon-publish', 'Writable', data.resolved);
|
||||
} else if (data.exists) {
|
||||
setStatus('text-warning', 'icon-warning-circle', 'Exists but not writable', data.resolved);
|
||||
} else {
|
||||
setStatus('text-danger', 'icon-unpublish', 'Directory not found', data.resolved);
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
setStatus('text-danger', 'icon-unpublish', 'Check failed: ' + err.message, null);
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
clearTimeout(checkTimer);
|
||||
checkTimer = setTimeout(checkDirPermissions, 400);
|
||||
});
|
||||
|
||||
input.addEventListener('change', function() {
|
||||
clearTimeout(checkTimer);
|
||||
checkDirPermissions();
|
||||
});
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
if (browser.style.display !== 'none') {
|
||||
browser.style.display = 'none';
|
||||
@@ -157,7 +241,7 @@ class FolderPickerField extends FormField
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(r) { if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')'); return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
tree.textContent = data.message || 'Error loading directory';
|
||||
@@ -205,11 +289,13 @@ class FolderPickerField extends FormField
|
||||
e.preventDefault();
|
||||
// Store with placeholders reversed back in
|
||||
input.value = unresolve(dir.path);
|
||||
checkDirPermissions();
|
||||
loadDir(dir.path);
|
||||
});
|
||||
item.addEventListener('dblclick', function(e) {
|
||||
e.preventDefault();
|
||||
input.value = unresolve(dir.path);
|
||||
checkDirPermissions();
|
||||
browser.style.display = 'none';
|
||||
});
|
||||
list.appendChild(item);
|
||||
@@ -232,6 +318,10 @@ class FolderPickerField extends FormField
|
||||
|
||||
tree.appendChild(info);
|
||||
}
|
||||
|
||||
// Run initial check on page load
|
||||
setDefaultDirWarning();
|
||||
checkDirPermissions();
|
||||
})();
|
||||
</script>
|
||||
HTML;
|
||||
|
||||
@@ -189,6 +189,7 @@ class DashboardModel extends BaseDatabaseModel
|
||||
->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]')
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
|
||||
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
|
||||
$db->setQuery($query);
|
||||
|
||||
@@ -22,6 +22,64 @@ class ProfileTable extends Table
|
||||
parent::__construct('#__mokojoombackup_profiles', 'id', $db);
|
||||
}
|
||||
|
||||
public function store($updateNulls = true): bool
|
||||
{
|
||||
$result = parent::store($updateNulls);
|
||||
|
||||
if ($result && !empty($this->backup_dir)) {
|
||||
$this->protectWebAccessibleDir($this->backup_dir);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function protectWebAccessibleDir(string $dir): void
|
||||
{
|
||||
// Resolve [DEFAULT_DIR] placeholder
|
||||
$defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
|
||||
$resolved = str_replace('[DEFAULT_DIR]', $defaultDir, $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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only protect directories under the web root
|
||||
$jRoot = realpath(JPATH_ROOT) ?: JPATH_ROOT;
|
||||
$realDir = realpath($resolved) ?: $resolved;
|
||||
|
||||
if (strpos($realDir, $jRoot) !== 0) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
if (empty($this->title)) {
|
||||
|
||||
@@ -137,10 +137,19 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</td>
|
||||
<td class="d-flex gap-1">
|
||||
<?php if ($item->status === 'complete' && $item->filesexist) : ?>
|
||||
<?php
|
||||
$isWebAccessible = !empty($item->absolute_path)
|
||||
&& strpos(realpath($item->absolute_path) ?: $item->absolute_path, realpath(JPATH_ROOT) ?: JPATH_ROOT) === 0;
|
||||
?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokojoombackup&task=backups.download&id=' . $item->id); ?>"
|
||||
class="btn btn-sm btn-outline-primary" title="<?php echo Text::_('COM_MOKOJOOMBACKUP_DOWNLOAD'); ?>">
|
||||
<span class="icon-download"></span>
|
||||
</a>
|
||||
<?php if ($isWebAccessible) : ?>
|
||||
<span class="badge bg-warning text-dark" title="<?php echo Text::_('COM_MOKOJOOMBACKUP_WEB_ACCESSIBLE_WARNING'); ?>">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mb-view-log"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>plg_actionlog_mokojoombackup</name>
|
||||
<version>01.04.00</version>
|
||||
<version>01.05.00-rc</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.04.00</version>
|
||||
<version>01.05.00-rc</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.04.00</version>
|
||||
<version>01.05.00-rc</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.04.00</version>
|
||||
<version>01.05.00-rc</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.04.00</version>
|
||||
<version>01.05.00-rc</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.04.00</version>
|
||||
<version>01.05.00-rc</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.04.00</version>
|
||||
<version>01.05.00-rc</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.04.00</version>
|
||||
<version>01.05.00-rc</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+43
-3
@@ -198,13 +198,53 @@ class Pkg_MokoJoomBackupInstallerScript
|
||||
mkdir($backupDir, 0755, true);
|
||||
|
||||
// Protect backup directory with .htaccess
|
||||
file_put_contents($backupDir . '/.htaccess', "Order deny,allow\nDeny from all\n");
|
||||
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>');
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if no license key configured
|
||||
$this->warnMissingLicenseKey();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
private function syncMenuIcons(): void
|
||||
{
|
||||
$iconMap = [
|
||||
'view=dashboard' => 'class:home',
|
||||
'view=backups' => 'class:database',
|
||||
'view=profiles' => 'class:cog',
|
||||
];
|
||||
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
|
||||
foreach ($iconMap as $linkFragment => $icon) {
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__menu'))
|
||||
->set($db->quoteName('img') . ' = ' . $db->quote($icon))
|
||||
->where($db->quoteName('client_id') . ' = 1')
|
||||
->where($db->quoteName('link') . ' LIKE ' . $db->quote('%com_mokojoombackup%' . $linkFragment . '%'));
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
}
|
||||
|
||||
// Set top-level component menu icon
|
||||
$query = $db->getQuery(true)
|
||||
->update($db->quoteName('#__menu'))
|
||||
->set($db->quoteName('img') . ' = ' . $db->quote('class:archive'))
|
||||
->where($db->quoteName('client_id') . ' = 1')
|
||||
->where($db->quoteName('link') . ' LIKE ' . $db->quote('index.php?option=com_mokojoombackup'))
|
||||
->where($db->quoteName('level') . ' = 1');
|
||||
$db->setQuery($query);
|
||||
$db->execute();
|
||||
} catch (\Throwable $e) {
|
||||
error_log('MokoJoomBackup: syncMenuIcons() failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user