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 - MokoJoomBackup MokoConsulting Full-site backup and restore for Joomla — database, files, and configuration - 01.04.00-dev + 01.05.00-dev GNU 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-rc 2026-06-02 Moko Consulting hello@mokoconsulting.tech @@ -40,6 +40,7 @@ COM_MOKOJOOMBACKUP + COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS COM_MOKOJOOMBACKUP_SUBMENU_PROFILES 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]" />
+