Created
May 13, 2026 09:12
-
-
Save andronex/494405966993629338de3b1371c5b921 to your computer and use it in GitHub Desktop.
Бэкапер сайта вместе с БД на MODX Revolution 2.7+ и даже MODX Evolution
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /* | |
| Что сделать: | |
| Сохранить файл как backup.php в корне сайта. | |
| В строке | |
| const BACKUP_KEY = 'CHANGE_THIS_TO_LONG_RANDOM_STRING'; | |
| поставить длинный случайный ключ. | |
| Открыть в браузере: | |
| https://ваш-сайт/backup.php?key=ВАШ_КЛЮЧ | |
| Скрипт соберёт архив и покажет ссылку на скачивание. | |
| После скачивания удалить: | |
| backup.php | |
| папку _site_backups_private вместе с архивом | |
| Что попадёт в архив: | |
| site/ — файлы сайта из корня | |
| database/database.sql — дамп БД | |
| external/core/ — если core у MODX вынесен за пределы web-root | |
| Практический момент: для очень больших сайтов такой PHP-скрипт может упереться в ограничения хостинга. Тогда лучше делать архив через CLI mysqldump + zip/tar, но для обычного shared/VPS этот вариант обычно хватает. | |
| */ | |
| declare(strict_types=1); | |
| error_reporting(E_ALL); | |
| ini_set('display_errors', '1'); | |
| ini_set('max_execution_time', '0'); | |
| @set_time_limit(0); | |
| @ini_set('memory_limit', '1024M'); | |
| header('Content-Type: text/html; charset=utf-8'); | |
| const BACKUP_KEY = 'CHANGE_THIS_TO_LONG_RANDOM_STRING'; | |
| const BACKUP_DIR_NAME = '_site_backups_private'; | |
| if (!extension_loaded('mysqli')) { | |
| http_response_code(500); | |
| exit('Ошибка: расширение mysqli не загружено.'); | |
| } | |
| if (!class_exists('ZipArchive')) { | |
| http_response_code(500); | |
| exit('Ошибка: расширение ZipArchive не загружено.'); | |
| } | |
| $key = (string)($_GET['key'] ?? ''); | |
| if (!hash_equals(BACKUP_KEY, $key)) { | |
| http_response_code(403); | |
| exit('403 Forbidden'); | |
| } | |
| $rootPath = realpath(__DIR__); | |
| if ($rootPath === false) { | |
| http_response_code(500); | |
| exit('Не удалось определить корень сайта.'); | |
| } | |
| $backupDir = $rootPath . DIRECTORY_SEPARATOR . BACKUP_DIR_NAME; | |
| ensureDir($backupDir); | |
| protectDir($backupDir); | |
| try { | |
| if (isset($_GET['download'])) { | |
| $requested = basename((string)$_GET['download']); | |
| downloadFile($backupDir, $requested); | |
| exit; | |
| } | |
| $config = loadModxConfig($rootPath); | |
| $timestamp = date('Y-m-d_H-i-s'); | |
| $baseName = 'backup_' . $config['cms'] . '_' . $timestamp; | |
| $tmpDir = $backupDir . DIRECTORY_SEPARATOR . $baseName; | |
| $zipPath = $backupDir . DIRECTORY_SEPARATOR . $baseName . '.zip'; | |
| $sqlPath = $tmpDir . DIRECTORY_SEPARATOR . 'database.sql'; | |
| $infoPath = $tmpDir . DIRECTORY_SEPARATOR . 'backup_info.txt'; | |
| ensureDir($tmpDir); | |
| dumpDatabase($config, $sqlPath); | |
| $info = []; | |
| $info[] = 'CMS: ' . $config['cms']; | |
| $info[] = 'Generated at: ' . date('c'); | |
| $info[] = 'Root path: ' . $rootPath; | |
| $info[] = 'DB host: ' . $config['db_host']; | |
| $info[] = 'DB name: ' . $config['db_name']; | |
| $info[] = 'Table prefix: ' . $config['table_prefix']; | |
| if (!empty($config['extra_paths'])) { | |
| $info[] = 'Extra paths:'; | |
| foreach ($config['extra_paths'] as $extraPath) { | |
| $info[] = ' - ' . $extraPath; | |
| } | |
| } | |
| file_put_contents($infoPath, implode(PHP_EOL, $info) . PHP_EOL); | |
| createZipArchive($zipPath, $rootPath, $config['extra_paths'], [ | |
| normalizePath($backupDir), | |
| normalizePath(__FILE__), | |
| ], [ | |
| 'database/database.sql' => $sqlPath, | |
| 'database/backup_info.txt' => $infoPath, | |
| ]); | |
| rrmdir($tmpDir); | |
| $downloadUrl = htmlspecialchars( | |
| basename($_SERVER['PHP_SELF']) . '?key=' . rawurlencode(BACKUP_KEY) . '&download=' . rawurlencode(basename($zipPath)), | |
| ENT_QUOTES, | |
| 'UTF-8' | |
| ); | |
| echo '<h2>Бэкап готов</h2>'; | |
| echo '<p><strong>Файл:</strong> ' . htmlspecialchars(basename($zipPath), ENT_QUOTES, 'UTF-8') . '</p>'; | |
| echo '<p><a href="' . $downloadUrl . '">Скачать архив</a></p>'; | |
| echo '<p>После скачивания удалите этот скрипт и архив с сервера.</p>'; | |
| } catch (Throwable $e) { | |
| http_response_code(500); | |
| echo '<h2>Ошибка</h2>'; | |
| echo '<pre>' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '</pre>'; | |
| } | |
| /** | |
| * ========================= | |
| * Helpers | |
| * ========================= | |
| */ | |
| function readPhpVars(string $file): array | |
| { | |
| return (static function (string $__file): array { | |
| require $__file; | |
| return get_defined_vars(); | |
| })($file); | |
| } | |
| function loadModxConfig(string $rootPath): array | |
| { | |
| $revoConfigCore = $rootPath . DIRECTORY_SEPARATOR . 'config.core.php'; | |
| $evoConfig = $rootPath . DIRECTORY_SEPARATOR . 'manager' . DIRECTORY_SEPARATOR . 'includes' . DIRECTORY_SEPARATOR . 'config.inc.php'; | |
| $extraPaths = []; | |
| // MODX Revolution | |
| if (is_file($revoConfigCore)) { | |
| // Этот require нужен, чтобы определить MODX_CORE_PATH | |
| require_once $revoConfigCore; | |
| if (!defined('MODX_CORE_PATH')) { | |
| throw new RuntimeException('Обнаружен config.core.php, но MODX_CORE_PATH не определён.'); | |
| } | |
| $configInc = rtrim(MODX_CORE_PATH, '/\\') . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'config.inc.php'; | |
| if (!is_file($configInc)) { | |
| throw new RuntimeException('Не найден файл конфигурации MODX Revolution: ' . $configInc); | |
| } | |
| $cfg = readPhpVars($configInc); | |
| $dbHost = $cfg['database_server'] ?? null; | |
| $dbUser = $cfg['database_user'] ?? null; | |
| $dbPass = $cfg['database_password'] ?? ''; | |
| $dbName = isset($cfg['dbase']) ? trim((string)$cfg['dbase'], '`') : null; | |
| $tablePrefix = (string)($cfg['table_prefix'] ?? ''); | |
| // Fallback из DSN, если какие-то поля не вытащились напрямую | |
| $dsn = (string)($cfg['database_dsn'] ?? ''); | |
| if (!$dbHost && preg_match('~host=([^;]+)~', $dsn, $m)) { | |
| $dbHost = $m[1]; | |
| } | |
| if (!$dbName && preg_match('~dbname=([^;]+)~', $dsn, $m)) { | |
| $dbName = $m[1]; | |
| } | |
| $corePath = realpath((string)MODX_CORE_PATH); | |
| if ($corePath !== false && !pathStartsWith($corePath, $rootPath)) { | |
| $extraPaths[] = $corePath; | |
| } | |
| if (!$dbHost || !$dbUser || !$dbName) { | |
| throw new RuntimeException( | |
| 'Не удалось прочитать параметры БД из MODX Revolution config.inc.php. ' . | |
| 'Проверьте файл: ' . $configInc | |
| ); | |
| } | |
| return [ | |
| 'cms' => 'modx_revolution', | |
| 'db_host' => (string)$dbHost, | |
| 'db_user' => (string)$dbUser, | |
| 'db_pass' => (string)$dbPass, | |
| 'db_name' => (string)$dbName, | |
| 'table_prefix' => $tablePrefix, | |
| 'extra_paths' => $extraPaths, | |
| ]; | |
| } | |
| // MODX Evolution | |
| if (is_file($evoConfig)) { | |
| $cfg = readPhpVars($evoConfig); | |
| $dbHost = $cfg['database_server'] ?? null; | |
| $dbUser = $cfg['database_user'] ?? null; | |
| $dbPass = $cfg['database_password'] ?? ''; | |
| $dbName = isset($cfg['dbase']) ? trim((string)$cfg['dbase'], '`') : null; | |
| $tablePrefix = (string)($cfg['table_prefix'] ?? ''); | |
| if (!$dbHost || !$dbUser || !$dbName) { | |
| throw new RuntimeException( | |
| 'Не удалось прочитать параметры БД из MODX Evolution config.inc.php. ' . | |
| 'Проверьте файл: ' . $evoConfig | |
| ); | |
| } | |
| return [ | |
| 'cms' => 'modx_evolution', | |
| 'db_host' => (string)$dbHost, | |
| 'db_user' => (string)$dbUser, | |
| 'db_pass' => (string)$dbPass, | |
| 'db_name' => (string)$dbName, | |
| 'table_prefix' => $tablePrefix, | |
| 'extra_paths' => [], | |
| ]; | |
| } | |
| throw new RuntimeException('Не удалось определить MODX. Ожидался Revolution или Evolution.'); | |
| } | |
| function dumpDatabase(array $config, string $sqlPath): void | |
| { | |
| $mysqli = @new mysqli( | |
| $config['db_host'], | |
| $config['db_user'], | |
| $config['db_pass'], | |
| $config['db_name'] | |
| ); | |
| if ($mysqli->connect_errno) { | |
| throw new RuntimeException('Ошибка подключения к БД: ' . $mysqli->connect_error); | |
| } | |
| if (!$mysqli->set_charset('utf8mb4')) { | |
| @$mysqli->set_charset('utf8'); | |
| } | |
| $fh = fopen($sqlPath, 'wb'); | |
| if (!$fh) { | |
| $mysqli->close(); | |
| throw new RuntimeException('Не удалось создать SQL-дамп: ' . $sqlPath); | |
| } | |
| fwrite($fh, "-- MODX backup\n"); | |
| fwrite($fh, "-- Generated at: " . date('c') . "\n"); | |
| fwrite($fh, "-- Database: `" . $config['db_name'] . "`\n\n"); | |
| fwrite($fh, "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n"); | |
| fwrite($fh, "SET time_zone = \"+00:00\";\n"); | |
| fwrite($fh, "SET FOREIGN_KEY_CHECKS=0;\n\n"); | |
| $objects = []; | |
| $result = $mysqli->query("SHOW FULL TABLES WHERE Table_type IN ('BASE TABLE','VIEW')"); | |
| if (!$result) { | |
| fclose($fh); | |
| $mysqli->close(); | |
| throw new RuntimeException('Не удалось получить список таблиц: ' . $mysqli->error); | |
| } | |
| while ($row = $result->fetch_array(MYSQLI_NUM)) { | |
| $objects[] = [ | |
| 'name' => (string)$row[0], | |
| 'type' => strtoupper((string)$row[1]), | |
| ]; | |
| } | |
| $result->free(); | |
| foreach ($objects as $object) { | |
| $name = $object['name']; | |
| $type = $object['type']; | |
| fwrite($fh, "\n-- ----------------------------\n"); | |
| fwrite($fh, "-- " . $type . ': ' . $name . "\n"); | |
| fwrite($fh, "-- ----------------------------\n\n"); | |
| if ($type === 'VIEW') { | |
| $createResult = $mysqli->query('SHOW CREATE VIEW ' . qid($name)); | |
| if (!$createResult) { | |
| fclose($fh); | |
| $mysqli->close(); | |
| throw new RuntimeException('SHOW CREATE VIEW failed for ' . $name . ': ' . $mysqli->error); | |
| } | |
| $createRow = $createResult->fetch_assoc(); | |
| $createValues = array_values((array)$createRow); | |
| $createSql = $createValues[1] ?? null; | |
| $createResult->free(); | |
| if (!$createSql) { | |
| fclose($fh); | |
| $mysqli->close(); | |
| throw new RuntimeException('Не удалось получить CREATE VIEW для ' . $name); | |
| } | |
| fwrite($fh, 'DROP VIEW IF EXISTS ' . qid($name) . ";\n"); | |
| fwrite($fh, $createSql . ";\n\n"); | |
| continue; | |
| } | |
| $createResult = $mysqli->query('SHOW CREATE TABLE ' . qid($name)); | |
| if (!$createResult) { | |
| fclose($fh); | |
| $mysqli->close(); | |
| throw new RuntimeException('SHOW CREATE TABLE failed for ' . $name . ': ' . $mysqli->error); | |
| } | |
| $createRow = $createResult->fetch_assoc(); | |
| $createValues = array_values((array)$createRow); | |
| $createSql = $createValues[1] ?? null; | |
| $createResult->free(); | |
| if (!$createSql) { | |
| fclose($fh); | |
| $mysqli->close(); | |
| throw new RuntimeException('Не удалось получить CREATE TABLE для ' . $name); | |
| } | |
| fwrite($fh, 'DROP TABLE IF EXISTS ' . qid($name) . ";\n"); | |
| fwrite($fh, $createSql . ";\n\n"); | |
| $dataResult = $mysqli->query('SELECT * FROM ' . qid($name), MYSQLI_USE_RESULT); | |
| if ($dataResult === false) { | |
| fclose($fh); | |
| $mysqli->close(); | |
| throw new RuntimeException('Не удалось прочитать данные таблицы ' . $name . ': ' . $mysqli->error); | |
| } | |
| $batch = []; | |
| $batchSize = 100; | |
| while ($row = $dataResult->fetch_assoc()) { | |
| $values = []; | |
| foreach ($row as $value) { | |
| $values[] = sqlValue($mysqli, $value); | |
| } | |
| $batch[] = '(' . implode(',', $values) . ')'; | |
| if (count($batch) >= $batchSize) { | |
| fwrite($fh, 'INSERT INTO ' . qid($name) . ' VALUES ' . implode(",\n", $batch) . ";\n"); | |
| $batch = []; | |
| } | |
| } | |
| if (!empty($batch)) { | |
| fwrite($fh, 'INSERT INTO ' . qid($name) . ' VALUES ' . implode(",\n", $batch) . ";\n"); | |
| } | |
| $dataResult->close(); | |
| fwrite($fh, "\n"); | |
| } | |
| fwrite($fh, "SET FOREIGN_KEY_CHECKS=1;\n"); | |
| fclose($fh); | |
| $mysqli->close(); | |
| } | |
| function createZipArchive(string $zipPath, string $rootPath, array $extraPaths, array $excludePaths, array $filesToAdd): void | |
| { | |
| $zip = new ZipArchive(); | |
| $openResult = $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); | |
| if ($openResult !== true) { | |
| throw new RuntimeException('Не удалось создать ZIP-архив: ' . $zipPath . ' (код ' . $openResult . ')'); | |
| } | |
| foreach ($filesToAdd as $localName => $realPath) { | |
| if (!is_file($realPath)) { | |
| $zip->close(); | |
| throw new RuntimeException('Файл не найден для добавления в архив: ' . $realPath); | |
| } | |
| $zip->addFile($realPath, str_replace('\\', '/', $localName)); | |
| } | |
| addDirectoryToZip($zip, $rootPath, 'site', $excludePaths); | |
| foreach ($extraPaths as $extraPath) { | |
| $label = 'external/' . basename($extraPath); | |
| addDirectoryToZip($zip, $extraPath, $label, $excludePaths); | |
| } | |
| $zip->close(); | |
| } | |
| function addDirectoryToZip(ZipArchive $zip, string $sourceDir, string $baseInZip, array $excludePaths): void | |
| { | |
| $sourceDir = normalizePath($sourceDir); | |
| $baseInZip = trim(str_replace('\\', '/', $baseInZip), '/'); | |
| $iterator = new RecursiveIteratorIterator( | |
| new RecursiveDirectoryIterator($sourceDir, FilesystemIterator::SKIP_DOTS), | |
| RecursiveIteratorIterator::SELF_FIRST | |
| ); | |
| foreach ($iterator as $item) { | |
| $path = normalizePath($item->getPathname()); | |
| if (shouldExcludePath($path, $excludePaths)) { | |
| continue; | |
| } | |
| $relative = ltrim(substr($path, strlen($sourceDir)), '/'); | |
| if ($relative === '') { | |
| continue; | |
| } | |
| $zipEntry = $baseInZip . '/' . $relative; | |
| if ($item->isDir()) { | |
| $zip->addEmptyDir($zipEntry); | |
| } elseif ($item->isFile()) { | |
| $zip->addFile($path, $zipEntry); | |
| } | |
| } | |
| } | |
| function shouldExcludePath(string $path, array $excludePaths): bool | |
| { | |
| foreach ($excludePaths as $exclude) { | |
| $exclude = normalizePath($exclude); | |
| if ($path === $exclude || pathStartsWith($path, $exclude)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| function qid(string $identifier): string | |
| { | |
| return '`' . str_replace('`', '``', $identifier) . '`'; | |
| } | |
| function sqlValue(mysqli $mysqli, $value): string | |
| { | |
| if ($value === null) { | |
| return 'NULL'; | |
| } | |
| return "'" . $mysqli->real_escape_string((string)$value) . "'"; | |
| } | |
| function ensureDir(string $dir): void | |
| { | |
| if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { | |
| throw new RuntimeException('Не удалось создать директорию: ' . $dir); | |
| } | |
| } | |
| function protectDir(string $dir): void | |
| { | |
| $htaccess = $dir . DIRECTORY_SEPARATOR . '.htaccess'; | |
| if (!file_exists($htaccess)) { | |
| file_put_contents( | |
| $htaccess, | |
| "Order deny,allow\nDeny from all\nRequire all denied\n" | |
| ); | |
| } | |
| $indexFile = $dir . DIRECTORY_SEPARATOR . 'index.html'; | |
| if (!file_exists($indexFile)) { | |
| file_put_contents($indexFile, ''); | |
| } | |
| } | |
| function rrmdir(string $dir): void | |
| { | |
| if (!is_dir($dir)) { | |
| return; | |
| } | |
| $iterator = new RecursiveIteratorIterator( | |
| new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), | |
| RecursiveIteratorIterator::CHILD_FIRST | |
| ); | |
| foreach ($iterator as $item) { | |
| if ($item->isDir()) { | |
| @rmdir($item->getPathname()); | |
| } else { | |
| @unlink($item->getPathname()); | |
| } | |
| } | |
| @rmdir($dir); | |
| } | |
| function normalizePath(string $path): string | |
| { | |
| return rtrim(str_replace('\\', '/', $path), '/'); | |
| } | |
| function pathStartsWith(string $path, string $prefix): bool | |
| { | |
| $path = normalizePath($path); | |
| $prefix = normalizePath($prefix); | |
| return $path === $prefix || strpos($path . '/', $prefix . '/') === 0; | |
| } | |
| function downloadFile(string $backupDir, string $fileName): void | |
| { | |
| $filePath = $backupDir . DIRECTORY_SEPARATOR . $fileName; | |
| $realFile = realpath($filePath); | |
| $realBackupDir = realpath($backupDir); | |
| if ($realFile === false || $realBackupDir === false || !pathStartsWith($realFile, $realBackupDir) || !is_file($realFile)) { | |
| http_response_code(404); | |
| exit('Файл не найден.'); | |
| } | |
| header_remove('Content-Type'); | |
| header('Content-Type: application/zip'); | |
| header('Content-Disposition: attachment; filename="' . basename($realFile) . '"'); | |
| header('Content-Length: ' . filesize($realFile)); | |
| header('Cache-Control: private'); | |
| header('Pragma: public'); | |
| $fp = fopen($realFile, 'rb'); | |
| if (!$fp) { | |
| http_response_code(500); | |
| exit('Не удалось открыть файл для скачивания.'); | |
| } | |
| while (!feof($fp)) { | |
| echo fread($fp, 1024 * 1024 * 8); | |
| flush(); | |
| } | |
| fclose($fp); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment