diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml
index b3c2c0b..89d06b5 100644
--- a/.mokogitea/manifest.xml
+++ b/.mokogitea/manifest.xml
@@ -5,7 +5,7 @@
Package - MokoJoomBackupMokoConsultingFull-site backup and restore for Joomla — database, files, and configuration
- 01.04.00-dev
+ 01.05.00-devGNU General Public License v3
diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml
index 90c323d..9b0d9ca 100644
--- a/.mokogitea/workflows/issue-branch.yml
+++ b/.mokogitea/workflows/issue-branch.yml
@@ -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"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e4b4a6..0880432 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index b2d3d4d..5fb3eba 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# MokoJoomBackup
-
+
Full-site backup and restore for Joomla — database, files, and configuration.
diff --git a/source/packages/com_mokojoombackup/forms/profile.xml b/source/packages/com_mokojoombackup/forms/profile.xml
index 34701a8..46aa934 100644
--- a/source/packages/com_mokojoombackup/forms/profile.xml
+++ b/source/packages/com_mokojoombackup/forms/profile.xml
@@ -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"
/>
com_mokojoombackup
- 01.04.00
+ 01.05.00-rc2026-06-02Moko Consultinghello@mokoconsulting.tech
@@ -40,6 +40,7 @@
+
diff --git a/source/packages/com_mokojoombackup/src/Controller/AjaxController.php b/source/packages/com_mokojoombackup/src/Controller/AjaxController.php
index e08da9f..c924acd 100644
--- a/source/packages/com_mokojoombackup/src/Controller/AjaxController.php
+++ b/source/packages/com_mokojoombackup/src/Controller/AjaxController.php
@@ -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.
*/
diff --git a/source/packages/com_mokojoombackup/src/Engine/BackupEngine.php b/source/packages/com_mokojoombackup/src/Engine/BackupEngine.php
index 62aa3eb..84b3c49 100644
--- a/source/packages/com_mokojoombackup/src/Engine/BackupEngine.php
+++ b/source/packages/com_mokojoombackup/src/Engine/BackupEngine.php
@@ -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\n Require all denied\n\n# Apache 2.2\n\n Order deny,allow\n Deny from all\n\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, '') === 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;
diff --git a/source/packages/com_mokojoombackup/src/Engine/PlaceholderResolver.php b/source/packages/com_mokojoombackup/src/Engine/PlaceholderResolver.php
index cbac2c9..a4afed0 100644
--- a/source/packages/com_mokojoombackup/src/Engine/PlaceholderResolver.php
+++ b/source/packages/com_mokojoombackup/src/Engine/PlaceholderResolver.php
@@ -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',
];
}
diff --git a/source/packages/com_mokojoombackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokojoombackup/src/Engine/SteppedBackupEngine.php
index e54b1b6..fc5af70 100644
--- a/source/packages/com_mokojoombackup/src/Engine/SteppedBackupEngine.php
+++ b/source/packages/com_mokojoombackup/src/Engine/SteppedBackupEngine.php
@@ -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\n Require all denied\n\n# Apache 2.2\n\n Order deny,allow\n Deny from all\n\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, '') === false) {
+ error_log('MokoJoomBackup: Could not create index.html in backup directory: ' . $dir);
+ }
+ }
+ }
+
private function parseNewlineList(string $text): array
{
if (empty($text)) {
diff --git a/source/packages/com_mokojoombackup/src/Engine/SteppedSession.php b/source/packages/com_mokojoombackup/src/Engine/SteppedSession.php
index 7f83d80..d34d726 100644
--- a/source/packages/com_mokojoombackup/src/Engine/SteppedSession.php
+++ b/source/packages/com_mokojoombackup/src/Engine/SteppedSession.php
@@ -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;
diff --git a/source/packages/com_mokojoombackup/src/Extension/MokoJoomBackupComponent.php b/source/packages/com_mokojoombackup/src/Extension/MokoJoomBackupComponent.php
index f5dc0d5..10cd7f0 100644
--- a/source/packages/com_mokojoombackup/src/Extension/MokoJoomBackupComponent.php
+++ b/source/packages/com_mokojoombackup/src/Extension/MokoJoomBackupComponent.php
@@ -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());
+ }
+ }
}
diff --git a/source/packages/com_mokojoombackup/src/Field/FolderPickerField.php b/source/packages/com_mokojoombackup/src/Field/FolderPickerField.php
index f0ac4d2..17bcaee 100644
--- a/source/packages/com_mokojoombackup/src/Field/FolderPickerField.php
+++ b/source/packages/com_mokojoombackup/src/Field/FolderPickerField.php
@@ -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
+ placeholder="[DEFAULT_DIR] or /home/user/backups/[host]" />
+
+
+ The default backup directory is inside the web root. Backup archives may be directly downloadable if .htaccess is not supported. For better security, use a path outside the web root.
+
@@ -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();
})();
HTML;
diff --git a/source/packages/com_mokojoombackup/src/Model/DashboardModel.php b/source/packages/com_mokojoombackup/src/Model/DashboardModel.php
index cc8045f..4435e54 100644
--- a/source/packages/com_mokojoombackup/src/Model/DashboardModel.php
+++ b/source/packages/com_mokojoombackup/src/Model/DashboardModel.php
@@ -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);
diff --git a/source/packages/com_mokojoombackup/src/Table/ProfileTable.php b/source/packages/com_mokojoombackup/src/Table/ProfileTable.php
index 41b1647..78892a7 100644
--- a/source/packages/com_mokojoombackup/src/Table/ProfileTable.php
+++ b/source/packages/com_mokojoombackup/src/Table/ProfileTable.php
@@ -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\n Require all denied\n\n# Apache 2.2\n\n Order deny,allow\n Deny from all\n\n") === false) {
+ error_log('MokoJoomBackup: Could not create .htaccess in: ' . $resolved);
+ }
+ }
+
+ $index = $resolved . '/index.html';
+
+ if (!is_file($index)) {
+ if (@file_put_contents($index, '') === false) {
+ error_log('MokoJoomBackup: Could not create index.html in: ' . $resolved);
+ }
+ }
+ }
+ }
+
public function check(): bool
{
if (empty($this->title)) {
diff --git a/source/packages/com_mokojoombackup/tmpl/backups/default.php b/source/packages/com_mokojoombackup/tmpl/backups/default.php
index fa79ddc..33f2042 100644
--- a/source/packages/com_mokojoombackup/tmpl/backups/default.php
+++ b/source/packages/com_mokojoombackup/tmpl/backups/default.php
@@ -137,10 +137,19 @@ $listDirn = $this->escape($this->state->get('list.direction'));