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
#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
278 lines
7.0 KiB
PHP
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);
|
|
}
|
|
}
|