* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; class DatabaseDumper { /** @var array Tables to exclude entirely (both structure and data) */ private array $excludeBoth = []; /** @var array Tables to exclude data only (structure is kept) */ private array $excludeDataOnly = []; /** @var array Tables to exclude structure only (data is kept — unusual) */ private array $excludeStructureOnly = []; private int $tablesCount = 0; /** @var bool Whether to sanitize user passwords */ private bool $sanitizePasswords = false; /** @var bool Whether to preserve super admin password when sanitizing */ private bool $preserveSuperAdmin = false; /** @var bool Whether to sanitize user emails */ private bool $sanitizeEmails = false; /** @var bool Whether to clear session data */ private bool $sanitizeSessions = false; /** Known invalid bcrypt hash used for sanitized passwords */ private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000'; /** * @param array $excludeTables Table names to exclude (with #__ prefix). * @param bool $sanitizePasswords Replace user password hashes with invalid value * @param bool $preserveSuperAdmin Keep super admin password when sanitizing * @param bool $sanitizeEmails Replace user emails with sanitized placeholders * @param bool $sanitizeSessions Skip session table data entirely */ public function __construct( array $excludeTables = [], bool $sanitizePasswords = false, bool $preserveSuperAdmin = false, bool $sanitizeEmails = false, bool $sanitizeSessions = false ) { foreach ($excludeTables as $entry) { if (str_ends_with($entry, ':data-only')) { $this->excludeDataOnly[] = substr($entry, 0, -10); } elseif (str_ends_with($entry, ':structure-only')) { $this->excludeStructureOnly[] = substr($entry, 0, -15); } else { $this->excludeBoth[] = $entry; } } $this->sanitizePasswords = $sanitizePasswords; $this->preserveSuperAdmin = $preserveSuperAdmin; $this->sanitizeEmails = $sanitizeEmails; $this->sanitizeSessions = $sanitizeSessions; /* If session sanitization is on, auto-exclude session table data */ if ($sanitizeSessions) { $this->excludeDataOnly[] = '#__session'; } } /** * Dump all database tables to SQL. * * @return string The SQL dump */ public function dump(): string { $db = Factory::getDbo(); $prefix = $db->getPrefix(); $output = []; $output[] = '-- MokoSuiteBackup Database Dump'; $output[] = '-- Generated: ' . date('Y-m-d H:i:s'); $output[] = '-- Server: ' . $db->getServerType(); $output[] = '-- Database: ' . $db->getName(); $output[] = '-- Original Prefix: ' . $prefix; $output[] = '-- Abstract Prefix: #__'; $output[] = '-- Note: Table names use #__ placeholder. Replace with your prefix on restore.'; $output[] = ''; $output[] = 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";'; $output[] = 'SET time_zone = "+00:00";'; $output[] = ''; // 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) { // Check if excluded $abstractName = '#__' . substr($table, strlen($prefix)); if ($this->isExcludedBoth($abstractName, $table)) { continue; } $skipData = $this->isExcludedDataOnly($abstractName, $table); $skipStructure = $this->isExcludedStructureOnly($abstractName, $table); $this->tablesCount++; $output[] = '-- --------------------------------------------------------'; $output[] = '-- Table: ' . $abstractName; if ($skipData) { $output[] = '-- (data excluded)'; } if ($skipStructure) { $output[] = '-- (structure excluded)'; } $output[] = '-- --------------------------------------------------------'; $output[] = ''; // Get CREATE TABLE statement (unless structure is excluded) if (!$skipStructure) { $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); $createRow = $db->loadRow(); if (!$createRow || empty($createRow[1])) { continue; } // Replace all occurrences of the live prefix with #__ in CREATE TABLE // output — covers the table itself and FK REFERENCES to other tables $createSql = str_replace('`' . $prefix, '`#__', $createRow[1]); $output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;'; $output[] = $createSql . ';'; $output[] = ''; } // Dump data (unless data is excluded) if ($skipData) { $output[] = ''; continue; } $db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table)); $rowCount = (int) $db->loadResult(); if ($rowCount === 0) { $output[] = '-- (empty table)'; $output[] = ''; 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) { $this->sanitizeRow($row, $abstractName, $db); $values = []; foreach ($row as $value) { if ($value === null) { $values[] = 'NULL'; } else { $values[] = $db->quote($value); } } $columns = array_map([$db, 'quoteName'], array_keys($row)); $output[] = 'INSERT INTO `' . $abstractName . '`' . ' (' . implode(', ', $columns) . ')' . ' VALUES (' . implode(', ', $values) . ');'; } } $output[] = ''; } return implode("\n", $output); } /** * Check if a table is fully excluded (both data and structure). */ private function isExcludedBoth(string $abstractName, string $realName): bool { foreach ($this->excludeBoth as $pattern) { if ($pattern === $abstractName || $pattern === $realName) { return true; } } return false; } /** * Check if a table's data is excluded (structure only). */ private function isExcludedDataOnly(string $abstractName, string $realName): bool { foreach ($this->excludeDataOnly as $pattern) { if ($pattern === $abstractName || $pattern === $realName) { return true; } } return false; } /** * Check if a table's structure is excluded (data only). */ private function isExcludedStructureOnly(string $abstractName, string $realName): bool { foreach ($this->excludeStructureOnly as $pattern) { if ($pattern === $abstractName || $pattern === $realName) { return true; } } 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) { $this->sanitizeRow($row, $abstractName, $db); $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; } /** * Sanitize a row if it belongs to the users table and sanitization is enabled. * * Replaces the password column with an invalid hash so the backup * cannot be used to extract user credentials. */ private function sanitizeRow(array &$row, string $abstractTable, object $db): void { if ($abstractTable !== '#__users') { return; } if (!$this->sanitizePasswords && !$this->sanitizeEmails) { return; } if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) { $userId = (int) $row['id']; /* Preserve super admin emails if preserving super admin */ if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) { $row['email'] = 'user' . $userId . '@sanitized.example.com'; } } if (!$this->sanitizePasswords || !isset($row['password'])) { return; } if ($this->preserveSuperAdmin && isset($row['id'])) { if ($this->isSuperAdmin((int) $row['id'], $db)) { return; } } $row['password'] = self::SANITIZED_HASH; } /** * Check if a user ID belongs to the Super Users group (group_id = 8). */ private function isSuperAdmin(int $userId, object $db): bool { static $superAdminIds = null; if ($superAdminIds === null) { $prefix = $db->getPrefix(); try { $db->setQuery( $db->getQuery(true) ->select('DISTINCT ' . $db->quoteName('user_id')) ->from($db->quoteName($prefix . 'user_usergroup_map')) ->where($db->quoteName('group_id') . ' = 8') ); $superAdminIds = array_map('intval', $db->loadColumn() ?: []); } catch (\Throwable $e) { $superAdminIds = []; } } return in_array($userId, $superAdminIds, true); } /** * Check if passwords were sanitized (for use by callers to log the action). */ public function isPasswordSanitizationEnabled(): bool { return $this->sanitizePasswords; } /** * Get the sentinel hash used for sanitized passwords. */ public static function getSanitizedHash(): string { return self::SANITIZED_HASH; } public function getTablesCount(): int { return $this->tablesCount; } }