Skip to content

Instantly share code, notes, and snippets.

@andronex
Created May 13, 2026 09:12
Show Gist options
  • Select an option

  • Save andronex/494405966993629338de3b1371c5b921 to your computer and use it in GitHub Desktop.

Select an option

Save andronex/494405966993629338de3b1371c5b921 to your computer and use it in GitHub Desktop.
Бэкапер сайта вместе с БД на MODX Revolution 2.7+ и даже MODX Evolution
<?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