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