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:
@@ -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
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user