Files
MokoSuiteBackup/source/packages/com_mokojoombackup/src/Engine/SteppedSession.php
T
Jonathan Miller 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
fix: address all PR review findings — error handling, security, validation
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>
2026-06-06 21:10:11 -05:00

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);
}
}
}
}