diff --git a/source/packages/com_mokosuitebackup/forms/profile.xml b/source/packages/com_mokosuitebackup/forms/profile.xml index 1944d47..c969bfb 100644 --- a/source/packages/com_mokosuitebackup/forms/profile.xml +++ b/source/packages/com_mokosuitebackup/forms/profile.xml @@ -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" /> 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; } /** diff --git a/source/packages/com_mokosuitebackup/src/Table/ProfileTable.php b/source/packages/com_mokosuitebackup/src/Table/ProfileTable.php index ac3bf58..fef06ca 100644 --- a/source/packages/com_mokosuitebackup/src/Table/ProfileTable.php +++ b/source/packages/com_mokosuitebackup/src/Table/ProfileTable.php @@ -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') { diff --git a/source/packages/com_mokosuitebackup/src/Utility/BackupDirectory.php b/source/packages/com_mokosuitebackup/src/Utility/BackupDirectory.php index 560f040..096121e 100644 --- a/source/packages/com_mokosuitebackup/src/Utility/BackupDirectory.php +++ b/source/packages/com_mokosuitebackup/src/Utility/BackupDirectory.php @@ -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; } diff --git a/source/script.php b/source/script.php index 2d5f30d..55ac7d9 100644 --- a/source/script.php +++ b/source/script.php @@ -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\n Require all denied\n\n# Apache 2.2\n\n Order deny,allow\n Deny from all\n\n"); - } - - $index = $backupDir . '/index.html'; - - if (!is_file($index)) { - file_put_contents($index, ''); - } + @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( - 'Backup Directory Warning — ' - . '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. ' - . 'Edit Profiles', - '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());