feat: change default backup dir to ../backups (outside web root)

- Default backup_dir is now ../backups (relative to JPATH_ROOT),
  which resolves outside public_html on most hosting setups
- Added BackupDirectory::normalizePath() to resolve ../ segments
  without requiring the path to exist on disk
- Added BackupDirectory::portablize() to auto-detect absolute paths
  and replace them with portable placeholders ([HOME], ../backups)
- ProfileTable::check() auto-normalizes backup_dir on save
- Install postflight auto-migrates old in-webroot defaults to ../backups
- Dashboard warning now checks resolved path instead of string matching
- .htaccess protection only applied when directory is inside web root
This commit is contained in:
Jonathan Miller
2026-06-11 12:39:59 -05:00
parent 8d78cae60b
commit 49f3d9fdcf
7 changed files with 132 additions and 46 deletions
@@ -67,7 +67,7 @@
type="FolderPicker"
label="COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR"
description="COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC"
default="[DEFAULT_DIR]"
default="../backups"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
@@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`archive_format` VARCHAR(10) NOT NULL DEFAULT 'zip',
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
`backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]',
`backup_dir` VARCHAR(512) NOT NULL DEFAULT '../backups',
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders',
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
@@ -81,7 +81,7 @@ INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
`published`, `ordering`, `created`, `modified`
) VALUES (
1, 'Default Backup Profile', 'Full site backup with default settings', 'full',
'zip', 5, 0, '[DEFAULT_DIR]',
'zip', 5, 0, '../backups',
'administrator/components/com_mokosuitebackup/backups\ntmp\ncache\nlogs\nadministrator/logs',
'.gitignore\n.htaccess.bak',
'#__session',
@@ -204,8 +204,12 @@ class FolderPickerField extends FormField
function setDefaultDirWarning() {
var warn = document.getElementById(fieldId + '_defaultwarn');
var val = input.value.trim();
var isDefault = (!val || val === '[DEFAULT_DIR]' || val === 'administrator/components/com_mokosuitebackup/backups' || val === 'administrator/components/com_mokojoombackup/backups');
if (warn) warn.style.display = isDefault ? 'block' : 'none';
var resolved = resolve(val);
var jRoot = placeholders['[DEFAULT_DIR]'].replace(/\/administrator\/components\/com_mokosuitebackup\/backups$/, '');
var isInsideWebRoot = resolved && resolved.indexOf(jRoot) === 0;
var isOldDefault = val === 'administrator/components/com_mokosuitebackup/backups' || val === 'administrator/components/com_mokojoombackup/backups' || val === '[DEFAULT_DIR]';
var showWarning = isOldDefault || isInsideWebRoot;
if (warn) warn.style.display = showWarning ? 'block' : 'none';
}
function checkDirPermissions() {
@@ -174,18 +174,28 @@ class DashboardModel extends BaseDatabaseModel
*/
public function isUsingDefaultBackupDir(): bool
{
$db = $this->getDatabase();
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('COUNT(*)')
->select($db->quoteName('backup_dir'))
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('published') . ' = 1')
->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)');
->where($db->quoteName('published') . ' = 1');
$db->setQuery($query);
$dirs = $db->loadColumn();
return (int) $db->loadResult() > 0;
// Warn only if any profile's resolved path is inside the web root
foreach ($dirs as $dir) {
$resolved = BackupDirectory::resolve($dir ?: BackupDirectory::DEFAULT_RELATIVE);
if (BackupDirectory::hasPlaceholders($resolved)) {
continue;
}
if (BackupDirectory::isWebAccessible($resolved)) {
return true;
}
}
return false;
}
/**
@@ -61,6 +61,11 @@ class ProfileTable extends Table
$this->backup_type = 'full';
}
// Normalize backup_dir to portable placeholder form
if (!empty($this->backup_dir)) {
$this->backup_dir = BackupDirectory::portablize($this->backup_dir);
}
$now = date('Y-m-d H:i:s');
if (empty($this->created) || $this->created === '0000-00-00 00:00:00') {
@@ -14,7 +14,7 @@ defined('_JEXEC') or die;
class BackupDirectory
{
public const DEFAULT_RELATIVE = 'administrator/components/com_mokosuitebackup/backups';
public const DEFAULT_RELATIVE = '../backups';
public const PLACEHOLDER = '[DEFAULT_DIR]';
@@ -107,10 +107,81 @@ HTACCESS;
}
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
return rtrim($dir, '/\\');
return self::normalizePath($dir);
}
return JPATH_ROOT . '/' . $dir;
return self::normalizePath(JPATH_ROOT . '/' . $dir);
}
/**
* Convert an absolute or literal path back to portable placeholder form.
*
* Replaces known absolute values with their placeholder tokens so
* the stored path remains portable across environments.
*
* @param string $dir Raw directory value (e.g. from database)
*
* @return string Path with placeholders restored where possible
*/
public static function portablize(string $dir): string
{
// Replace the old literal default with the relative default
$oldDefault = 'administrator/components/com_mokosuitebackup/backups';
$oldDefaultAlt = 'administrator/components/com_mokojoombackup/backups';
if ($dir === $oldDefault || $dir === $oldDefaultAlt || $dir === self::PLACEHOLDER) {
return self::DEFAULT_RELATIVE;
}
// Replace absolute default path with relative default
$absDefault = self::getDefaultAbsolute();
if ($dir === $absDefault) {
return self::DEFAULT_RELATIVE;
}
// Replace absolute HOME prefix with [HOME]
$home = self::getHomeDirectory();
if ($home !== '' && strpos($dir, $home . '/') === 0) {
$dir = self::HOME_PLACEHOLDER . substr($dir, \strlen($home));
}
return $dir;
}
/**
* Normalize a path by resolving `.` and `..` segments without requiring
* the path to exist on disk (unlike realpath).
*/
public static function normalizePath(string $path): string
{
$path = str_replace('\\', '/', $path);
$prefix = '';
// Preserve leading slash (Unix) or drive letter (Windows)
if (isset($path[0]) && $path[0] === '/') {
$prefix = '/';
} elseif (preg_match('#^([A-Za-z]:/)#', $path, $m)) {
$prefix = $m[1];
$path = substr($path, \strlen($prefix));
}
$parts = [];
foreach (explode('/', $path) as $seg) {
if ($seg === '' || $seg === '.') {
continue;
}
if ($seg === '..' && $parts && end($parts) !== '..') {
array_pop($parts);
} else {
$parts[] = $seg;
}
}
return rtrim($prefix . implode('/', $parts), '/');
}
/**
@@ -173,7 +244,10 @@ HTACCESS;
}
}
self::protect($dir);
// Only add .htaccess/index.html when inside the web root
if (self::isWebAccessible($dir)) {
self::protect($dir);
}
return true;
}
+22 -29
View File
@@ -191,25 +191,11 @@ class Pkg_MokoSuiteBackupInstallerScript
$db->setQuery($query);
$db->execute();
// Create and protect default backup directory
$backupDir = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/backups';
// Create default backup directory (outside web root)
$backupDir = JPATH_ROOT . '/../backups';
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
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>');
}
@mkdir($backupDir, 0755, true);
}
// Create default scheduled task — every 30 days, profile 1
@@ -247,26 +233,33 @@ class Pkg_MokoSuiteBackupInstallerScript
{
try {
$db = Factory::getDbo();
// Check for profiles using the old in-webroot default
$oldDefaults = [
'administrator/components/com_mokosuitebackup/backups',
'administrator/components/com_mokojoombackup/backups',
'[DEFAULT_DIR]',
];
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('published') . ' = 1')
->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote('administrator/components/com_mokosuitebackup/backups')
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('[DEFAULT_DIR]')
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
->where('(' . $db->quoteName('backup_dir') . ' IN ('
. implode(',', array_map([$db, 'quote'], $oldDefaults))
. ') 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_mokosuitebackup&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'
);
// Auto-migrate old defaults to the new ../backups convention
$update = $db->getQuery(true)
->update($db->quoteName('#__mokosuitebackup_profiles'))
->set($db->quoteName('backup_dir') . ' = ' . $db->quote('../backups'))
->where('(' . $db->quoteName('backup_dir') . ' IN ('
. implode(',', array_map([$db, 'quote'], $oldDefaults))
. ') OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
$db->setQuery($update);
$db->execute();
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: warnDefaultBackupDir() failed: ' . $e->getMessage());