Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Utility/BackupDirectory.php
T
Jonathan Miller 899a33bc58
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 10s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Universal: PR Check / Validate PR (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 13s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4m50s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
feat: purge, CPanel module, 7z format, SFTP browser (#119, #105, #122, #98)
#119: Manual purge — toolbar button opens modal with date picker,
AJAX count preview, confirmation before bulk delete.

#105: CPanel admin dashboard module (mod_mokosuitebackup_cpanel) —
backup status, quick action buttons per profile, next scheduled,
stats, and quick links. Registered in package manifest.

#122: 7z archive format via system 7za/7z CLI binary with optional
password encryption. New SevenZipArchiver engine class.

#98: SFTP remote file browser — custom SftpPathField with "Browse
Remote" button, modal directory listing via SSH ls, click to navigate,
double-click to select.

Also: CHANGELOG updated, wiki Home updated, #121 verified (encryption
field already visible in Archive Settings tab).

Closes #119, closes #105, closes #122, closes #98, closes #121
2026-06-23 13:05:42 -05:00

278 lines
7.0 KiB
PHP

<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoSuiteBackup\Administrator\Utility;
defined('_JEXEC') or die;
class BackupDirectory
{
public const DEFAULT_RELATIVE = './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>';
public const HOME_PLACEHOLDER = '[HOME]';
/**
* Get the absolute default backup directory path.
*/
public static function getDefaultAbsolute(): string
{
return JPATH_ROOT . '/backups';
}
/**
* Detect the home directory of the PHP process owner.
*
* Tries multiple sources because PHP-FPM on shared hosting
* often strips shell environment variables.
*
* @return string Absolute home path, or empty string if undetectable
*/
public static function getHomeDirectory(): string
{
// 1. Environment variables (works in CLI and some FPM configs)
$home = getenv('HOME') ?: ($_SERVER['HOME'] ?? '');
if ($home === '') {
$home = getenv('USERPROFILE') ?: ($_SERVER['USERPROFILE'] ?? '');
}
// 2. POSIX: read from /etc/passwd (most reliable on Linux)
if ($home === '' && \function_exists('posix_getpwuid') && \function_exists('posix_geteuid')) {
$info = posix_getpwuid(posix_geteuid());
if ($info && !empty($info['dir'])) {
$home = $info['dir'];
}
}
// 3. Derive from JPATH_ROOT — walk up until we find a home-like path
if ($home === '' && \defined('JPATH_ROOT')) {
$parts = explode('/', rtrim(JPATH_ROOT, '/'));
// Typical pattern: /home/{user}/domain/public_html
if (count($parts) >= 3 && $parts[1] === 'home') {
$home = '/' . $parts[1] . '/' . $parts[2];
}
}
return rtrim($home, '/\\');
}
/**
* Resolve a backup directory path. Replaces [DEFAULT_DIR] and [HOME]
* placeholders, 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);
}
// Resolve [HOME] placeholder
if (strpos($dir, self::HOME_PLACEHOLDER) !== false) {
$home = self::getHomeDirectory();
if ($home !== '') {
$dir = str_replace(self::HOME_PLACEHOLDER, $home, $dir);
}
}
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
return self::normalizePath($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
{
// Known defaults all map back to [DEFAULT_DIR]
$knownDefaults = [
self::PLACEHOLDER,
self::DEFAULT_RELATIVE,
'./backups',
'backups',
'administrator/components/com_mokosuitebackup/backups',
'administrator/components/com_mokojoombackup/backups',
self::getDefaultAbsolute(),
];
if (\in_array($dir, $knownDefaults, true)) {
return self::PLACEHOLDER;
}
// 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), '/');
}
/**
* 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('MokoSuiteBackup: 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('MokoSuiteBackup: 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;
}
}
// Only add .htaccess/index.html when inside the web root
if (self::isWebAccessible($dir)) {
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|7z)$/i', '.log', $archivePath);
}
}