11 Commits

Author SHA1 Message Date
gitea-actions[bot] 9af3b7ba9d chore(release): build 01.08.00 [skip ci] 2026-06-07 15:06:21 +00:00
jmiller abe68c1e64 Merge pull request 'Release v01.07.00 — scheduled task, install warnings, AkeebaImporter cleanup' (#44) from dev into main
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
2026-06-07 15:06:14 +00:00
gitea-actions[bot] 4d74627720 chore(version): pre-release bump to 01.07.01-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 10s
2026-06-07 14:59:37 +00:00
Jonathan Miller e5fb88e1a4 feat: auto-create default scheduled task on install, fix AkeebaImporter constants
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
- Create "Monthly Full Backup" scheduled task (30-day interval, profile 1,
  03:00 execution) on fresh install via com_scheduler
- Skips if any MokoJoomBackup task already exists
- Failure notifications enabled to Super Users group by default
- Replace hardcoded backup dir paths in AkeebaImporter with
  BackupDirectory::DEFAULT_RELATIVE constant
2026-06-07 09:50:32 -05:00
Jonathan Miller df44518611 fix: skip all postflight actions on uninstall, add install warnings
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
- Early return on uninstall to prevent license warning, menu sync, and
  default-dir check from running during package removal
- Add warnDefaultBackupDir() — warns on install/update if any profile
  uses the default web-root backup directory
- Add profile review reminder on fresh install with link to profiles view
2026-06-07 09:46:46 -05:00
gitea-actions[bot] 5e0683c0c7 chore(release): build 01.07.00 [skip ci] 2026-06-07 14:44:24 +00:00
jmiller bbf3dcd449 Merge pull request 'Refactor: extract BackupDirectory utility class' (#43) from dev into main
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
2026-06-07 14:44:09 +00:00
gitea-actions[bot] 139423cbe9 chore(version): pre-release bump to 01.06.01-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 18s
2026-06-07 14:41:18 +00:00
Jonathan Miller 814d1b147c refactor: extract BackupDirectory utility to eliminate code duplication
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
- Create BackupDirectory utility class with centralized:
  - DEFAULT_RELATIVE constant and PLACEHOLDER constant
  - resolve() — path resolution with [DEFAULT_DIR] and relative path handling
  - hasPlaceholders() — check for unresolved placeholder tokens
  - isWebAccessible() — web-root boundary check
  - protect() — .htaccess and index.html creation with error logging
  - ensureReady() — mkdir + protect in one call
  - parseNewlineList() — newline-separated text parsing
  - logPathFromArchive() — derive .log path from archive path
- Remove duplicated methods from BackupEngine, SteppedBackupEngine,
  ProfileTable, AjaxController, and DashboardModel
- All consumers now use BackupDirectory static methods
- Net reduction: ~180 lines of duplicated code eliminated
2026-06-07 09:39:43 -05:00
gitea-actions[bot] 6dc7fdfa91 chore(release): build 01.06.00 [skip ci] 2026-06-07 14:23:16 +00:00
jmiller 3de786cf71 Merge pull request 'Release v01.05.00 — dashboard menu, [DEFAULT_DIR], live validation, security hardening' (#42) from rc into main 2026-06-07 14:23:05 +00:00
24 changed files with 342 additions and 264 deletions
+1 -1
View File
@@ -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.08.00-dev</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
# VERSION: 01.05.00
# VERSION: 01.08.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+6 -63
View File
@@ -1,6 +1,12 @@
# Changelog
## [Unreleased]
## [01.08.00] --- 2026-06-07
## [01.07.00] --- 2026-06-07
## [01.06.00] --- 2026-06-07
## [01.05.00] --- 2026-06-07
@@ -23,66 +29,3 @@
- Dashboard submenu language key added to `.sys.ini` files (en-GB, en-US)
## [01.04.00] --- 2026-06-07
## [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 -1
View File
@@ -1,6 +1,6 @@
# MokoJoomBackup
<!-- VERSION: 01.05.00 -->
<!-- VERSION: 01.08.00 -->
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.08.00</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.08.00</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.08.00</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.08.00</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.08.00</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.08.00</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.08.00</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.08.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoJoomBackup</name>
<packagename>mokojoombackup</packagename>
<version>01.05.00-rc</version>
<version>01.08.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+129 -8
View File
@@ -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());
}
}