From 5e5d79327b1e7889e2d0bea5340f91ffd2e31cb3 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 2 Jun 2026 15:37:38 -0500 Subject: [PATCH] feat: AJAX step-based backup engine for shared hosting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On shared hosting where max_execution_time cannot be overridden, backups now run as a series of small AJAX requests. Each request does one unit of work (dump one table, add one batch of files) and returns within the time limit. - SteppedBackupEngine: orchestrates init → database → files → finalize → upload → complete phases - SteppedSession: persists state between requests via temp JSON files with automatic cleanup of stale sessions (24h) - AjaxController: handles init and step requests with CSRF protection - Admin UI: progress bar modal with real-time phase and percentage updates, auto-reloads on completion - Steps: 1 table per DB step, 200 files per file step (configurable) Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 5 + .../src/Controller/AjaxController.php | 85 +++ .../src/Engine/SteppedBackupEngine.php | 550 ++++++++++++++++++ .../src/Engine/SteppedSession.php | 181 ++++++ .../com_mokobackup/tmpl/backups/default.php | 121 ++++ 5 files changed, 942 insertions(+) create mode 100644 src/packages/com_mokobackup/src/Controller/AjaxController.php create mode 100644 src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php create mode 100644 src/packages/com_mokobackup/src/Engine/SteppedSession.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6205ea8..f9b524c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ - "Include Restore Script" toggle per profile — wraps backup with restore.php + site-backup.zip - FileRestorer class with protected file handling (preserves configuration.php, .htaccess) - DatabaseImporter with streaming line-by-line SQL execution and error tolerance +- AJAX step-based backup engine for shared hosting (overcomes max_execution_time) +- SteppedBackupEngine: breaks backup into per-table DB dumps and file batches +- SteppedSession: persistent state between AJAX requests via temp JSON files +- Progress bar modal in admin UI with real-time phase/percentage updates +- AjaxController for init/step endpoints with CSRF protection - Per-profile archive settings: format, compression level, split size, backup directory - Backup engine with step-based execution for large sites - Database dumper with table-level granularity diff --git a/src/packages/com_mokobackup/src/Controller/AjaxController.php b/src/packages/com_mokobackup/src/Controller/AjaxController.php new file mode 100644 index 0000000..c904f53 --- /dev/null +++ b/src/packages/com_mokobackup/src/Controller/AjaxController.php @@ -0,0 +1,85 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * AJAX controller for step-based backups. + * Handles init and step requests from the admin UI JavaScript. + */ + +namespace Joomla\Component\MokoBackup\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Session\Session; +use Joomla\Component\MokoBackup\Administrator\Engine\SteppedBackupEngine; + +class AjaxController extends BaseController +{ + /** + * Initialize a new stepped backup. + * POST: task=ajax.init&profile_id=1&description=... + */ + public function init(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token']); + + return; + } + + $profileId = $this->input->getInt('profile_id', 1); + $description = $this->input->getString('description', ''); + + $engine = new SteppedBackupEngine(); + $result = $engine->init($profileId, $description, 'backend'); + + $this->sendJson($result); + } + + /** + * Run the next step of a backup session. + * POST: task=ajax.step&session_id=mb_... + */ + public function step(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token']); + + return; + } + + $sessionId = $this->input->getString('session_id', ''); + + if (empty($sessionId)) { + $this->sendJson(['error' => true, 'message' => 'Missing session_id']); + + return; + } + + $engine = new SteppedBackupEngine(); + $result = $engine->runStep($sessionId); + + $this->sendJson($result); + } + + /** + * Send a JSON response and close the application. + */ + private function sendJson(array $data): void + { + $app = $this->app; + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + $app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + $app->sendHeaders(); + + echo json_encode($data); + + $app->close(); + } +} diff --git a/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php b/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php new file mode 100644 index 0000000..02ef27a --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php @@ -0,0 +1,550 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * AJAX step-based backup engine for shared hosting. + * + * Each call to runStep() performs one unit of work within the PHP time + * limit, saves state, and returns. The browser JS fires the next step. + * + * This overcomes max_execution_time restrictions on shared hosting + * where ini_set() and set_time_limit() are disabled. + */ + +namespace Joomla\Component\MokoBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +class SteppedBackupEngine +{ + /** + * Initialize a new stepped backup session. + * + * @return array{session_id: string, phase: string, progress: int, message: string} + */ + public function init(int $profileId, string $description = '', string $origin = 'backend'): array + { + $db = Factory::getDbo(); + + // Load profile + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokobackup_profiles')) + ->where($db->quoteName('id') . ' = ' . $profileId); + $db->setQuery($query); + $profile = $db->loadObject(); + + if (!$profile) { + return ['error' => true, 'message' => 'Profile not found: ' . $profileId]; + } + + // Create session + $session = SteppedSession::create(); + $session->profileId = $profileId; + $session->origin = $origin; + $session->backupType = $profile->backup_type; + + // Parse profile settings + $session->excludeDirs = $this->parseNewlineList($profile->exclude_dirs ?? ''); + $session->excludeFiles = $this->parseNewlineList($profile->exclude_files ?? ''); + $session->excludeTables = $this->parseNewlineList($profile->exclude_tables ?? ''); + $session->backupDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups'; + $session->remoteStorage = $profile->remote_storage ?? 'none'; + $session->includeKickstart = (bool) ($profile->include_kickstart ?? false); + $session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); + + // Build archive path + $backupDir = JPATH_ROOT . '/' . $session->backupDir; + + if (!is_dir($backupDir)) { + mkdir($backupDir, 0755, true); + } + + $now = date('Y-m-d H:i:s'); + $tag = date('Ymd_His'); + $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); + $archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.zip'; + + $session->archivePath = $backupDir . '/' . $archiveName; + $session->archiveName = $archiveName; + $session->description = $description ?: ($profile->title . ' — ' . $now); + + // Create backup record + $record = (object) [ + 'profile_id' => $profileId, + 'description' => $session->description, + 'status' => 'running', + 'origin' => $origin, + 'backup_type' => $profile->backup_type, + 'archivename' => $archiveName, + 'absolute_path' => $session->archivePath, + 'total_size' => 0, + 'db_size' => 0, + 'files_count' => 0, + 'tables_count' => 0, + 'multipart' => 0, + 'tag' => $tag, + 'backupstart' => $now, + 'backupend' => '0000-00-00 00:00:00', + 'filesexist' => 0, + 'remote_filename' => '', + 'log' => '', + ]; + + $db->insertObject('#__mokobackup_records', $record, 'id'); + $session->recordId = $record->id; + + // Determine what work needs to be done and estimate steps + $totalSteps = 1; // init step + + if ($profile->backup_type !== 'files') { + // Count tables for database phase + $tables = $this->getSiteTables($session->excludeTables); + $session->tables = $tables; + $totalSteps += count($tables); // one step per table + } + + if ($profile->backup_type !== 'database') { + // Scan files and split into batches + $scanner = new FileScanner(JPATH_ROOT, $session->excludeDirs, $session->excludeFiles); + $allFiles = $scanner->scan(); + $session->filesCount = count($allFiles); + $session->fileBatches = array_chunk($allFiles, $session->batchSize); + $totalSteps += count($session->fileBatches); // one step per batch + } + + $totalSteps += 1; // finalize step + $totalSteps += ($session->remoteStorage !== 'none') ? 1 : 0; // upload step + + $session->totalSteps = $totalSteps; + $session->currentStep = 1; + $session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files'; + $session->log('Backup initialized: ' . $session->description); + $session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')'); + $session->statusMessage = 'Initialized — starting backup...'; + $session->save(); + + return [ + 'session_id' => $session->sessionId, + 'phase' => $session->phase, + 'progress' => $session->getProgress(), + 'message' => $session->statusMessage, + ]; + } + + /** + * Run the next step of a backup session. + * + * @return array{session_id: string, phase: string, progress: int, message: string, done?: bool} + */ + public function runStep(string $sessionId): array + { + $session = SteppedSession::load($sessionId); + + if (!$session) { + return ['error' => true, 'message' => 'Session not found: ' . $sessionId]; + } + + try { + switch ($session->phase) { + case 'database': + $this->stepDatabase($session); + break; + + case 'files': + $this->stepFiles($session); + break; + + case 'finalize': + $this->stepFinalize($session); + break; + + case 'upload': + $this->stepUpload($session); + break; + + case 'complete': + $session->destroy(); + + return [ + 'session_id' => $sessionId, + 'phase' => 'complete', + 'progress' => 100, + 'message' => 'Backup complete: ' . $session->archiveName, + 'done' => true, + ]; + } + + $session->save(); + + return [ + 'session_id' => $sessionId, + 'phase' => $session->phase, + 'progress' => $session->getProgress(), + 'message' => $session->statusMessage, + 'done' => $session->phase === 'complete', + ]; + } catch (\Throwable $e) { + $session->log('FATAL: ' . $e->getMessage()); + $this->failRecord($session, $e->getMessage()); + $session->destroy(); + + return ['error' => true, 'message' => 'Backup failed: ' . $e->getMessage()]; + } + } + + /** + * Database phase: dump one table per step. + */ + private function stepDatabase(SteppedSession $session): void + { + if ($session->tableIndex >= count($session->tables)) { + // Database phase complete, move to files or finalize + $session->phase = ($session->backupType !== 'database') ? 'files' : 'finalize'; + $session->tablesCount = $session->tableIndex; + $session->log('Database dump complete: ' . $session->tablesCount . ' tables'); + + return; + } + + $table = $session->tables[$session->tableIndex]; + $db = Factory::getDbo(); + + // Dump this single table + $dumper = new DatabaseDumper([]); + $sql = $this->dumpSingleTable($db, $table); + + // Append to a temp SQL file that will be added to ZIP in finalize + $sqlFile = $session->archivePath . '.sql'; + $flags = $session->tableIndex === 0 ? 0 : FILE_APPEND; + + if ($session->tableIndex === 0) { + $header = "-- MokoJoomBackup Database Dump\n" + . "-- Generated: " . date('Y-m-d H:i:s') . "\n" + . "-- Prefix: " . $db->getPrefix() . "\n\n" + . "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n" + . "SET time_zone = \"+00:00\";\n\n"; + file_put_contents($sqlFile, $header); + $flags = FILE_APPEND; + } + + file_put_contents($sqlFile, $sql, $flags); + $session->dbSize += strlen($sql); + + $session->tableIndex++; + $session->currentStep++; + $session->statusMessage = 'Dumping table ' . $session->tableIndex . '/' . count($session->tables) . ': ' . $table; + $session->log('Dumped table: ' . $table); + } + + /** + * Files phase: add one batch of files to ZIP per step. + */ + private function stepFiles(SteppedSession $session): void + { + if ($session->batchIndex >= count($session->fileBatches)) { + $session->phase = 'finalize'; + $session->log('Files phase complete: ' . $session->filesCount . ' files in ' . count($session->fileBatches) . ' batches'); + + return; + } + + $batch = $session->fileBatches[$session->batchIndex]; + $zip = new \ZipArchive(); + $mode = $session->batchIndex === 0 + ? (\ZipArchive::CREATE | \ZipArchive::OVERWRITE) + : \ZipArchive::CREATE; + + if ($zip->open($session->archivePath, $mode) !== true) { + throw new \RuntimeException('Cannot open archive for writing'); + } + + $added = 0; + + foreach ($batch as $relativePath) { + $fullPath = JPATH_ROOT . '/' . $relativePath; + + if (is_file($fullPath) && is_readable($fullPath)) { + $zip->addFile($fullPath, $relativePath); + $added++; + } + } + + $zip->close(); + + $session->batchIndex++; + $session->currentStep++; + $batchNum = $session->batchIndex; + $totalBatches = count($session->fileBatches); + $session->statusMessage = "Adding files batch {$batchNum}/{$totalBatches} ({$added} files)"; + $session->log("Files batch {$batchNum}: {$added} files added"); + } + + /** + * Finalize phase: add database.sql to ZIP, apply kickstart wrapper. + */ + private function stepFinalize(SteppedSession $session): void + { + $zip = new \ZipArchive(); + + if ($zip->open($session->archivePath, \ZipArchive::CREATE) !== true) { + throw new \RuntimeException('Cannot open archive for finalization'); + } + + // Add database dump if it exists + $sqlFile = $session->archivePath . '.sql'; + + if (is_file($sqlFile)) { + $zip->addFile($sqlFile, 'database.sql'); + } + + $zip->close(); + + // Clean up temp SQL file + if (is_file($sqlFile)) { + @unlink($sqlFile); + } + + $totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0; + + // Kickstart wrapper + if ($session->includeKickstart) { + $session->log('Wrapping with Kickstart restore script...'); + $kickstartPath = $session->archivePath . '.kickstart.zip'; + Kickstart::wrap($session->archivePath, $kickstartPath); + @unlink($session->archivePath); + rename($kickstartPath, $session->archivePath); + $totalSize = filesize($session->archivePath); + $session->log('Kickstart archive created'); + } + + // Update record + $db = Factory::getDbo(); + $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; + + $update = (object) [ + 'id' => $session->recordId, + 'total_size' => $totalSize, + 'db_size' => $session->dbSize, + 'files_count' => $session->filesCount, + 'tables_count' => $session->tablesCount, + 'filesexist' => 1, + ]; + + $db->updateObject('#__mokobackup_records', $update, 'id'); + + $session->currentStep++; + $session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete'; + $session->statusMessage = 'Archive finalized: ' . $sizeHuman; + $session->log('Archive finalized: ' . $sizeHuman); + + if ($session->phase === 'complete') { + $this->completeRecord($session); + } + } + + /** + * Upload phase: send archive to remote storage. + */ + private function stepUpload(SteppedSession $session): void + { + $db = Factory::getDbo(); + + // Reload profile for remote settings + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokobackup_profiles')) + ->where($db->quoteName('id') . ' = ' . $session->profileId); + $db->setQuery($query); + $profile = $db->loadObject(); + + $uploader = match ($session->remoteStorage) { + 'ftp' => new FtpUploader($profile), + 'google_drive' => new GoogleDriveUploader($profile), + default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage), + }; + + $session->log('Starting remote upload (' . $session->remoteStorage . ')...'); + $result = $uploader->upload($session->archivePath, $session->archiveName); + + $remoteFilename = ''; + + if ($result['success']) { + $remoteFilename = $result['remote_path'] ?? $session->archiveName; + $session->log('Remote upload complete: ' . $result['message']); + + if (!$session->remoteKeepLocal && is_file($session->archivePath)) { + @unlink($session->archivePath); + $session->log('Local copy removed'); + } + } else { + $session->log('WARNING: Remote upload failed: ' . $result['message']); + } + + // Update record with remote filename + $update = (object) [ + 'id' => $session->recordId, + 'remote_filename' => $remoteFilename, + 'filesexist' => is_file($session->archivePath) ? 1 : 0, + ]; + + $db->updateObject('#__mokobackup_records', $update, 'id'); + + $session->currentStep++; + $session->phase = 'complete'; + $session->statusMessage = 'Backup complete'; + $this->completeRecord($session); + } + + /** + * Mark the backup record as complete. + */ + private function completeRecord(SteppedSession $session): void + { + $db = Factory::getDbo(); + $update = (object) [ + 'id' => $session->recordId, + 'status' => 'complete', + 'backupend' => date('Y-m-d H:i:s'), + 'log' => implode("\n", $session->log), + ]; + + $db->updateObject('#__mokobackup_records', $update, 'id'); + } + + /** + * Mark the backup record as failed. + */ + private function failRecord(SteppedSession $session, string $error): void + { + $db = Factory::getDbo(); + $update = (object) [ + 'id' => $session->recordId, + 'status' => 'fail', + 'backupend' => date('Y-m-d H:i:s'), + 'log' => implode("\n", $session->log), + ]; + + $db->updateObject('#__mokobackup_records', $update, 'id'); + } + + /** + * Dump a single table to SQL string. + */ + private function dumpSingleTable(object $db, string $table): string + { + $output = []; + $output[] = '-- --------------------------------------------------------'; + $output[] = '-- Table: ' . $table; + $output[] = '-- --------------------------------------------------------'; + $output[] = ''; + + // CREATE TABLE + $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); + $createRow = $db->loadRow(); + + if (!$createRow || empty($createRow[1])) { + return ''; + } + + $output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';'; + $output[] = $createRow[1] . ';'; + $output[] = ''; + + // Data in chunks + $db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table)); + $rowCount = (int) $db->loadResult(); + + if ($rowCount === 0) { + $output[] = '-- (empty table)'; + $output[] = ''; + + return implode("\n", $output); + } + + $chunkSize = 500; + + for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) { + $db->setQuery( + $db->getQuery(true)->select('*')->from($db->quoteName($table)), + $offset, + $chunkSize + ); + $rows = $db->loadAssocList(); + + if (empty($rows)) { + break; + } + + foreach ($rows as $row) { + $values = []; + + foreach ($row as $value) { + $values[] = $value === null ? 'NULL' : $db->quote($value); + } + + $columns = array_map([$db, 'quoteName'], array_keys($row)); + $output[] = 'INSERT INTO ' . $db->quoteName($table) + . ' (' . implode(', ', $columns) . ')' + . ' VALUES (' . implode(', ', $values) . ');'; + } + } + + $output[] = ''; + + return implode("\n", $output); + } + + /** + * Get site tables (with prefix), excluding filtered tables. + */ + private function getSiteTables(array $excludeTables): array + { + $db = Factory::getDbo(); + $prefix = $db->getPrefix(); + $tables = []; + + foreach ($db->getTableList() as $table) { + if (!str_starts_with($table, $prefix)) { + continue; + } + + $abstractName = '#__' . substr($table, strlen($prefix)); + + $excluded = false; + + foreach ($excludeTables as $pattern) { + if ($pattern === $abstractName || $pattern === $table) { + $excluded = true; + + break; + } + } + + if (!$excluded) { + $tables[] = $table; + } + } + + return $tables; + } + + private 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 !== '' + )); + } +} diff --git a/src/packages/com_mokobackup/src/Engine/SteppedSession.php b/src/packages/com_mokobackup/src/Engine/SteppedSession.php new file mode 100644 index 0000000..b4f3002 --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/SteppedSession.php @@ -0,0 +1,181 @@ + + * @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\MokoBackup\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 $includeKickstart = 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/mokobackup-sessions'; + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + 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); + file_put_contents($path, json_encode(get_object_vars($this), JSON_PRETTY_PRINT)); + } + + /** + * 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); + } + } + } +} diff --git a/src/packages/com_mokobackup/tmpl/backups/default.php b/src/packages/com_mokobackup/tmpl/backups/default.php index 65073bd..f0a4f93 100644 --- a/src/packages/com_mokobackup/tmpl/backups/default.php +++ b/src/packages/com_mokobackup/tmpl/backups/default.php @@ -14,9 +14,13 @@ use Joomla\CMS\HTML\HTMLHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\Layout\LayoutHelper; use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; HTMLHelper::_('behavior.multiselect'); +$ajaxToken = Session::getFormToken(); +$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false); + $listOrder = $this->escape($this->state->get('list.ordering')); $listDirn = $this->escape($this->state->get('list.direction')); ?> @@ -129,3 +133,120 @@ $listDirn = $this->escape($this->state->get('list.direction')); + + + + +