026b72deed
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (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
Generic: Repo Health / Site Health (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Universal: Auto Version Bump / Version Bump (push) Has been cancelled
Universal: PR Check / Branch Policy (pull_request) Has been cancelled
Generic: Repo Health / Site Health (pull_request) Has been cancelled
Generic: Repo Health / Access control (pull_request) Has been cancelled
Joomla: Extension CI / Release Readiness Check (pull_request) Has been cancelled
Joomla: Extension CI / Lint & Validate (pull_request) Has been cancelled
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Has been cancelled
Universal: PR Check / Validate PR (pull_request) Has been cancelled
Security: - browseDir restricted to JPATH_ROOT and current user $HOME (not all /home/) - MokoRestore db_prefix validated with regex to prevent SQL injection - MokoRestore DB import returns failure when zero statements succeed Error handling (fatal — would produce corrupt backups): - BackupEngine/SteppedEngine mkdir() checked, returns error on failure - SteppedSession save() checked, throws on write failure - SteppedEngine SQL dump file_put_contents checked, throws on failure - MokoRestore configuration.php write checked, throws on failure Error handling (logged — secondary operations): - BackupEngine dispatchAfterRun catch block logs to error_log - BackupEngine/SteppedEngine log file write failures logged - NotificationSender user group email resolution logged - script.php download key save/restore logged Operational fixes: - Cleanup plugin: don't delete DB record if file unlink fails (prevents orphans) - BackupEngine: count and log skipped unreadable files - BackupEngine: handle MokoRestore rename failure gracefully - SteppedEngine: add S3Uploader to stepUpload match (feature parity) Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
186 lines
4.1 KiB
PHP
186 lines
4.1 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoJoomBackup
|
|
* @subpackage com_mokojoombackup
|
|
* @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
|
|
*
|
|
* Manages state for AJAX step-based backups.
|
|
*
|
|
* On shared hosting where max_execution_time cannot be overridden,
|
|
* the backup runs as a series of small AJAX requests. Each request
|
|
* loads the session, does one chunk of work, saves state, and returns.
|
|
* The browser JS fires the next step automatically.
|
|
*
|
|
* Phases: init → database → files → finalize → upload → complete
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
class SteppedSession
|
|
{
|
|
public string $sessionId;
|
|
public string $phase = 'init';
|
|
public int $recordId = 0;
|
|
public int $profileId = 0;
|
|
public string $archivePath = '';
|
|
public string $archiveName = '';
|
|
public string $description = '';
|
|
public string $origin = 'backend';
|
|
|
|
// Database phase tracking
|
|
public array $tables = [];
|
|
public int $tableIndex = 0;
|
|
public int $tablesCount = 0;
|
|
public int $dbSize = 0;
|
|
|
|
// Files phase tracking
|
|
public array $fileBatches = [];
|
|
public int $batchIndex = 0;
|
|
public int $filesCount = 0;
|
|
public int $batchSize = 200;
|
|
|
|
// Profile settings (cached so we don't re-query each step)
|
|
public string $backupType = 'full';
|
|
public string $backupDir = '';
|
|
public array $excludeDirs = [];
|
|
public array $excludeFiles = [];
|
|
public array $excludeTables = [];
|
|
public string $remoteStorage = 'none';
|
|
public bool $includeMokoRestore = false;
|
|
public bool $remoteKeepLocal = true;
|
|
|
|
// Progress
|
|
public int $totalSteps = 0;
|
|
public int $currentStep = 0;
|
|
public string $statusMessage = '';
|
|
public array $log = [];
|
|
|
|
private static function getSessionDir(): string
|
|
{
|
|
$dir = JPATH_ROOT . '/tmp/mokojoombackup-sessions';
|
|
|
|
if (!is_dir($dir)) {
|
|
if (!mkdir($dir, 0755, true)) {
|
|
throw new \RuntimeException('Cannot create session directory: ' . $dir);
|
|
}
|
|
}
|
|
|
|
return $dir;
|
|
}
|
|
|
|
private static function getSessionPath(string $sessionId): string
|
|
{
|
|
// Sanitize session ID to prevent path traversal
|
|
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '', $sessionId);
|
|
|
|
return self::getSessionDir() . '/' . $safe . '.json';
|
|
}
|
|
|
|
/**
|
|
* Create a new session.
|
|
*/
|
|
public static function create(): self
|
|
{
|
|
$session = new self();
|
|
$session->sessionId = 'mb_' . bin2hex(random_bytes(8));
|
|
|
|
return $session;
|
|
}
|
|
|
|
/**
|
|
* Load an existing session from disk.
|
|
*/
|
|
public static function load(string $sessionId): ?self
|
|
{
|
|
$path = self::getSessionPath($sessionId);
|
|
|
|
if (!is_file($path)) {
|
|
return null;
|
|
}
|
|
|
|
$data = json_decode(file_get_contents($path), true);
|
|
|
|
if (!$data) {
|
|
return null;
|
|
}
|
|
|
|
$session = new self();
|
|
|
|
foreach ($data as $key => $value) {
|
|
if (property_exists($session, $key)) {
|
|
$session->$key = $value;
|
|
}
|
|
}
|
|
|
|
return $session;
|
|
}
|
|
|
|
/**
|
|
* Save session state to disk.
|
|
*/
|
|
public function save(): void
|
|
{
|
|
$path = self::getSessionPath($this->sessionId);
|
|
if (file_put_contents($path, json_encode(get_object_vars($this), JSON_PRETTY_PRINT)) === false) {
|
|
throw new \RuntimeException('Cannot save backup session: ' . $path);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete session file.
|
|
*/
|
|
public function destroy(): void
|
|
{
|
|
$path = self::getSessionPath($this->sessionId);
|
|
|
|
if (is_file($path)) {
|
|
@unlink($path);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a log entry.
|
|
*/
|
|
public function log(string $message): void
|
|
{
|
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
|
}
|
|
|
|
/**
|
|
* Calculate progress percentage.
|
|
*/
|
|
public function getProgress(): int
|
|
{
|
|
if ($this->totalSteps <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return min(100, (int) round(($this->currentStep / $this->totalSteps) * 100));
|
|
}
|
|
|
|
/**
|
|
* Clean up old session files (older than 24 hours).
|
|
*/
|
|
public static function cleanupOldSessions(): void
|
|
{
|
|
$dir = self::getSessionDir();
|
|
|
|
if (!is_dir($dir)) {
|
|
return;
|
|
}
|
|
|
|
$cutoff = time() - 86400;
|
|
|
|
foreach (glob($dir . '/mb_*.json') as $file) {
|
|
if (filemtime($file) < $cutoff) {
|
|
@unlink($file);
|
|
}
|
|
}
|
|
}
|
|
}
|