diff --git a/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php b/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php index 5e32b37..8072e1a 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php +++ b/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php @@ -360,16 +360,12 @@ class AkeebaImporter return $result; } - // Try JSON + // Parse as JSON only — unserialize is an object injection risk $data = json_decode($raw, true); if (!is_array($data)) { - // Try unserialize (older Akeeba versions) - $data = @unserialize($raw); - - if (!is_array($data)) { - return $result; - } + // Older Akeeba versions used serialized PHP — skip rather than risk object injection + return $result; } // Extract directory exclusions diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index 77fae79..fb09db5 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -85,6 +85,7 @@ class BackupEngine $now = date('Y-m-d H:i:s'); $tag = $resolver->getTag(); $archiveFormat = $profile->archive_format ?? 'zip'; + $archiveName = ''; $archiver = $this->createArchiver($archiveFormat); $archiveExt = $archiver->getExtension(); $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; @@ -130,12 +131,15 @@ class BackupEngine $tablesCount = 0; // Step 1: Database dump (unless files-only) + // Streams to a temp file to avoid loading the entire dump into RAM + $sqlTempFile = ''; + if ($profile->backup_type !== 'files') { $this->log('Starting database dump...'); - $dumper = new DatabaseDumper($excludeTables); - $sqlDump = $dumper->dump(); - $archiver->addFromString('database.sql', $sqlDump); - $dbSize = strlen($sqlDump); + $sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql'; + $dumper = new DatabaseDumper($excludeTables); + $dbSize = $dumper->dumpToFile($sqlTempFile); + $archiver->addFile($sqlTempFile, 'database.sql'); $tablesCount = $dumper->getTablesCount(); $this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes'); } @@ -203,6 +207,11 @@ class BackupEngine $archiver->close(); + // Clean up temp SQL file (no longer needed after archive is closed) + if (!empty($sqlTempFile) && is_file($sqlTempFile)) { + @unlink($sqlTempFile); + } + // Step 1.5: Apply AES-256 encryption (if configured) $encryptionPassword = $profile->encryption_password ?? ''; @@ -315,6 +324,17 @@ class BackupEngine } catch (\Throwable $e) { $this->log('FATAL: ' . $e->getMessage()); + // Clean up temp SQL file on failure + if (!empty($sqlTempFile) && is_file($sqlTempFile)) { + @unlink($sqlTempFile); + } + + // If encryption was intended and failed, remove the plaintext archive + if (!empty($encryptionPassword) && !empty($archivePath) && is_file($archivePath)) { + @unlink($archivePath); + $this->log('Plaintext archive removed after encryption failure'); + } + $update = (object) [ 'id' => $recordId, 'status' => 'fail', @@ -402,7 +422,7 @@ class BackupEngine return match ($format) { 'zip' => new ZipArchiver(), 'tar.gz' => new TarGzArchiver(), - default => new ZipArchiver(), + default => throw new \InvalidArgumentException('Unknown archive format: ' . $format), }; } diff --git a/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php b/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php index 4beb213..a1e1536 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php +++ b/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php @@ -219,6 +219,138 @@ class DatabaseDumper return false; } + /** + * Dump all database tables directly to a file, streaming row by row. + * Avoids loading the entire dump into RAM. + * + * @param string $filePath Absolute path to write the SQL file + * + * @return int Size of the dump file in bytes + */ + public function dumpToFile(string $filePath): int + { + $db = Factory::getDbo(); + $prefix = $db->getPrefix(); + + $fp = fopen($filePath, 'w'); + + if ($fp === false) { + throw new \RuntimeException('Cannot open dump file for writing: ' . $filePath); + } + + fwrite($fp, "-- MokoSuiteBackup Database Dump\n"); + fwrite($fp, "-- Generated: " . date('Y-m-d H:i:s') . "\n"); + fwrite($fp, "-- Server: " . $db->getServerType() . "\n"); + fwrite($fp, "-- Database: " . $db->getName() . "\n"); + fwrite($fp, "-- Original Prefix: " . $prefix . "\n"); + fwrite($fp, "-- Abstract Prefix: #__\n"); + fwrite($fp, "-- Note: Table names use #__ placeholder. Replace with your prefix on restore.\n\n"); + fwrite($fp, "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n"); + fwrite($fp, "SET time_zone = \"+00:00\";\n\n"); + + // Get all tables with the site prefix + $tables = $db->getTableList(); + $siteTables = []; + + foreach ($tables as $table) { + if (str_starts_with($table, $prefix)) { + $siteTables[] = $table; + } + } + + foreach ($siteTables as $table) { + $abstractName = '#__' . substr($table, strlen($prefix)); + + if ($this->isExcludedBoth($abstractName, $table)) { + continue; + } + + $skipData = $this->isExcludedDataOnly($abstractName, $table); + $skipStructure = $this->isExcludedStructureOnly($abstractName, $table); + + $this->tablesCount++; + + fwrite($fp, "-- --------------------------------------------------------\n"); + fwrite($fp, "-- Table: " . $abstractName . "\n"); + + if ($skipData) { + fwrite($fp, "-- (data excluded)\n"); + } + + if ($skipStructure) { + fwrite($fp, "-- (structure excluded)\n"); + } + + fwrite($fp, "-- --------------------------------------------------------\n\n"); + + if (!$skipStructure) { + $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); + $createRow = $db->loadRow(); + + if (!$createRow || empty($createRow[1])) { + continue; + } + + $createSql = str_replace('`' . $prefix, '`#__', $createRow[1]); + fwrite($fp, 'DROP TABLE IF EXISTS `' . $abstractName . "`;\\n"); + fwrite($fp, $createSql . ";\n\n"); + } + + if ($skipData) { + fwrite($fp, "\n"); + continue; + } + + $db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table)); + $rowCount = (int) $db->loadResult(); + + if ($rowCount === 0) { + fwrite($fp, "-- (empty table)\n\n"); + continue; + } + + $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) { + if ($value === null) { + $values[] = 'NULL'; + } else { + $values[] = $db->quote($value); + } + } + + $columns = array_map([$db, 'quoteName'], array_keys($row)); + fwrite($fp, 'INSERT INTO `' . $abstractName . '`' + . ' (' . implode(', ', $columns) . ')' + . ' VALUES (' . implode(', ', $values) . ");\n"); + } + } + + fwrite($fp, "\n"); + } + + fclose($fp); + + return filesize($filePath) ?: 0; + } + public function getTablesCount(): int { return $this->tablesCount; diff --git a/source/packages/com_mokosuitebackup/src/Engine/S3Uploader.php b/source/packages/com_mokosuitebackup/src/Engine/S3Uploader.php index 8b65c06..5c70a2c 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/S3Uploader.php +++ b/source/packages/com_mokosuitebackup/src/Engine/S3Uploader.php @@ -114,19 +114,28 @@ class S3Uploader implements RemoteUploaderInterface */ private function singleUpload(string $localPath, string $objectKey): void { - $url = $this->getObjectUrl($objectKey); - $fileContent = file_get_contents($localPath); - $contentHash = hash('sha256', $fileContent); + $url = $this->getObjectUrl($objectKey); + $fileSize = filesize($localPath); + + // Stream file to compute SHA-256 without loading into RAM + $contentHash = hash_file('sha256', $localPath); $headers = $this->signRequest('PUT', $url, $contentHash, [ 'Content-Type' => 'application/zip', - 'Content-Length' => (string) strlen($fileContent), + 'Content-Length' => (string) $fileSize, ]); + $fp = fopen($localPath, 'rb'); + + if ($fp === false) { + throw new \RuntimeException('Cannot open file for upload: ' . $localPath); + } + $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, - CURLOPT_CUSTOMREQUEST => 'PUT', - CURLOPT_POSTFIELDS => $fileContent, + CURLOPT_PUT => true, + CURLOPT_INFILE => $fp, + CURLOPT_INFILESIZE => $fileSize, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 600, @@ -135,6 +144,8 @@ class S3Uploader implements RemoteUploaderInterface $response = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + fclose($fp); + if (curl_errno($ch)) { $error = curl_error($ch); curl_close($ch); diff --git a/source/packages/com_mokosuitebackup/src/Engine/TarGzArchiver.php b/source/packages/com_mokosuitebackup/src/Engine/TarGzArchiver.php index e2ed018..48db23f 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/TarGzArchiver.php +++ b/source/packages/com_mokosuitebackup/src/Engine/TarGzArchiver.php @@ -47,12 +47,14 @@ class TarGzArchiver implements ArchiverInterface public function close(): void { - // Compress the .tar to .tar.gz - $this->tar->compress(\Phar::GZ); - - // Remove the uncompressed .tar - if (is_file($this->tarPath)) { - @unlink($this->tarPath); + try { + // Compress the .tar to .tar.gz + $this->tar->compress(\Phar::GZ); + } finally { + // Always remove the uncompressed .tar, even if compress() fails + if (is_file($this->tarPath)) { + @unlink($this->tarPath); + } } }