mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-01 08:45:44 +00:00
427 lines
17 KiB
PHP
427 lines
17 KiB
PHP
<?php
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------------
|
|
| AVE.cms
|
|
|--------------------------------------------------------------------------------------
|
|
| @package AVE.cms
|
|
| @file adminx/modules/Database/BackupRestore.php
|
|
| @author AVE.cms <support@ave-cms.ru>
|
|
| @copyright 2007-2026 (c) AVE.cms
|
|
| @link https://ave-cms.ru
|
|
| @version 3.3
|
|
*/
|
|
|
|
namespace App\Adminx\Database;
|
|
|
|
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
|
|
|
use App\Common\Cache;
|
|
use App\Common\CompiledModuleRegistry;
|
|
use App\Common\Session;
|
|
use App\Common\Twig;
|
|
use DB;
|
|
|
|
/** Проверяет и восстанавливает полный дамп с обязательной страховочной копией. */
|
|
class BackupRestore
|
|
{
|
|
const MAX_ARCHIVE_BYTES = 536870912;
|
|
const INSPECTION_SESSION_KEY = 'database_backup_inspection';
|
|
const RUN_SESSION_KEY = 'database_backup_restore_run';
|
|
|
|
public static function inspect($name, $includeToken = true)
|
|
{
|
|
$path = Backup::path($name);
|
|
if ($path === null || strpos(basename($path), 'adminx_') !== 0) {
|
|
throw new \RuntimeException('Для восстановления выберите полный дамп, созданный AVE.cms');
|
|
}
|
|
|
|
$size = (int) filesize($path);
|
|
if ($size <= 0 || $size > self::MAX_ARCHIVE_BYTES) {
|
|
throw new \RuntimeException('Размер резервной копии недопустим');
|
|
}
|
|
|
|
$summary = SqlDumpReader::scan($path);
|
|
$summary['name'] = basename($path);
|
|
$summary['size'] = $size;
|
|
$summary['size_h'] = Model::human($size);
|
|
$summary['hash'] = hash_file('sha256', $path);
|
|
$summary['mtime'] = (int) filemtime($path);
|
|
$summary['current_database'] = Model::databaseName();
|
|
$summary['current_prefixes'] = implode(', ', Model::prefixes());
|
|
$summary['warnings'] = self::warnings($summary);
|
|
if ($includeToken) {
|
|
$summary['token'] = self::token($summary);
|
|
Session::set(self::INSPECTION_SESSION_KEY, $summary);
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
|
|
public static function prepare($name, $token, $actorId)
|
|
{
|
|
$summary = Session::get(self::INSPECTION_SESSION_KEY);
|
|
if (!is_array($summary) || !isset($summary['name']) || !hash_equals((string) $summary['name'], basename((string) $name))) {
|
|
throw new \RuntimeException('Проверка резервной копии устарела. Выполните её повторно');
|
|
}
|
|
|
|
if (!hash_equals(self::token($summary), (string) $token)) {
|
|
throw new \RuntimeException('Проверка резервной копии устарела. Выполните её повторно');
|
|
}
|
|
|
|
$path = Backup::path($summary['name']);
|
|
if ($path === null || (int) filesize($path) !== (int) $summary['size']
|
|
|| (int) filemtime($path) !== (int) $summary['mtime']
|
|
|| !hash_equals((string) $summary['hash'], (string) hash_file('sha256', $path))) {
|
|
throw new \RuntimeException('Файл изменился после проверки. Выполните проверку повторно');
|
|
}
|
|
|
|
$id = date('YmdHis') . '-' . bin2hex(random_bytes(6));
|
|
$runToken = bin2hex(random_bytes(24));
|
|
$job = array(
|
|
'id' => $id,
|
|
'actor_id' => (int) $actorId,
|
|
'status' => 'prepared',
|
|
'stage' => 'prepare',
|
|
'progress' => 2,
|
|
'title' => 'Восстановление подготовлено',
|
|
'detail' => 'Ожидание запуска защищённого процесса',
|
|
'table' => '',
|
|
'current' => 0,
|
|
'total' => (int) $summary['table_count'],
|
|
'error' => '',
|
|
'result' => array(),
|
|
'created_at' => date(DATE_ATOM),
|
|
'updated_at' => date(DATE_ATOM),
|
|
);
|
|
self::saveJob($job);
|
|
Session::set(self::RUN_SESSION_KEY, array(
|
|
'job_id' => $id,
|
|
'token_hash' => hash('sha256', $runToken),
|
|
'summary' => $summary,
|
|
));
|
|
Session::del(self::INSPECTION_SESSION_KEY);
|
|
|
|
return array('job' => $job, 'run_token' => $runToken);
|
|
}
|
|
|
|
public static function status($id, $actorId)
|
|
{
|
|
$job = self::loadJob($id);
|
|
if ((int) $job['actor_id'] !== (int) $actorId) {
|
|
throw new \RuntimeException('Задание восстановления принадлежит другому пользователю');
|
|
}
|
|
|
|
return $job;
|
|
}
|
|
|
|
public static function restore($jobId, $token, callable $progress = null)
|
|
{
|
|
$run = Session::get(self::RUN_SESSION_KEY);
|
|
if (!is_array($run) || empty($run['job_id']) || !hash_equals((string) $run['job_id'], (string) $jobId)
|
|
|| empty($run['token_hash']) || !hash_equals((string) $run['token_hash'], hash('sha256', (string) $token))
|
|
|| empty($run['summary']) || !is_array($run['summary'])) {
|
|
throw new \RuntimeException('Запуск восстановления устарел. Проверьте копию повторно');
|
|
}
|
|
|
|
$summary = $run['summary'];
|
|
$job = self::loadJob($jobId);
|
|
Session::del(self::RUN_SESSION_KEY);
|
|
$emit = function (array $payload) use (&$job, $progress) {
|
|
$job = array_merge($job, $payload);
|
|
$job['updated_at'] = date(DATE_ATOM);
|
|
self::saveJob($job);
|
|
if ($progress) { call_user_func($progress, $job); }
|
|
};
|
|
$emit(array('status' => 'running', 'stage' => 'verify', 'progress' => 5, 'title' => 'Проверяем исходный файл', 'detail' => $summary['name']));
|
|
|
|
$lockPath = Backup::dir() . DS . '.adminx-restore.lock';
|
|
$lock = fopen($lockPath, 'c');
|
|
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
|
|
if ($lock) { fclose($lock); }
|
|
$message = 'Другое восстановление уже выполняется';
|
|
$emit(array('status' => 'failed', 'stage' => 'failed', 'progress' => 100, 'title' => 'Восстановление не запущено', 'detail' => $message, 'error' => $message));
|
|
throw new \RuntimeException($message);
|
|
}
|
|
|
|
$safety = null;
|
|
try {
|
|
$emit(array('stage' => 'backup', 'progress' => 10, 'title' => 'Создаём страховочную копию', 'detail' => 'Рабочая база пока не изменяется'));
|
|
$safety = Backup::create('before_restore', function (array $state) use ($emit) {
|
|
$total = max(1, (int) $state['total']);
|
|
$percent = 10 + (int) floor(((max(1, (int) $state['current']) - 1) / $total) * 25);
|
|
$emit(array(
|
|
'stage' => 'backup',
|
|
'progress' => $percent,
|
|
'title' => 'Страховочная копия',
|
|
'detail' => (string) $state['table'] . (!empty($state['rows']) ? ', строк: ' . (int) $state['rows'] : ''),
|
|
'table' => (string) $state['table'],
|
|
'current' => (int) $state['current'],
|
|
'total' => (int) $state['total'],
|
|
));
|
|
});
|
|
$path = Backup::path($summary['name']);
|
|
if ($path === null || !hash_equals($summary['hash'], hash_file('sha256', $path))) {
|
|
throw new \RuntimeException('Файл изменился после проверки. Восстановление отменено');
|
|
}
|
|
|
|
$emit(array('stage' => 'clean', 'progress' => 37, 'title' => 'Подготавливаем рабочую схему', 'detail' => 'Удаляем таблицы текущей установки', 'table' => '', 'current' => 0));
|
|
self::dropCurrentTables(function ($table, $current, $total) use ($emit) {
|
|
$emit(array(
|
|
'stage' => 'clean',
|
|
'progress' => 37 + (int) floor(($current / max(1, $total)) * 6),
|
|
'title' => 'Подготавливаем рабочую схему',
|
|
'detail' => $table,
|
|
'table' => $table,
|
|
'current' => $current,
|
|
'total' => $total,
|
|
));
|
|
});
|
|
$emit(array('stage' => 'restore', 'progress' => 44, 'title' => 'Восстанавливаем таблицы', 'detail' => 'Начинаем импорт резервной копии', 'current' => 0, 'total' => (int) $summary['table_count']));
|
|
self::apply($path, (int) $summary['table_count'], function ($table, $current, $total, $statements) use ($emit) {
|
|
$emit(array(
|
|
'stage' => 'restore',
|
|
'progress' => 44 + (int) floor(($current / max(1, $total)) * 45),
|
|
'title' => 'Восстанавливаем таблицы',
|
|
'detail' => $table . ', SQL-команд: ' . $statements,
|
|
'table' => $table,
|
|
'current' => $current,
|
|
'total' => $total,
|
|
));
|
|
});
|
|
$emit(array('stage' => 'cache', 'progress' => 92, 'title' => 'Обновляем runtime', 'detail' => 'Очищаем кеши и реестр модулей', 'table' => '', 'current' => 0));
|
|
self::clearRuntimeCaches();
|
|
} catch (\Throwable $e) {
|
|
$originalMessage = $e->getMessage();
|
|
$rollbackOk = false;
|
|
if ($safety && !empty($safety['name'])) {
|
|
try {
|
|
$emit(array('stage' => 'rollback', 'progress' => 94, 'title' => 'Возвращаем исходную базу', 'detail' => $originalMessage, 'table' => '', 'current' => 0));
|
|
self::dropCurrentTables();
|
|
self::apply(Backup::path($safety['name']), 0, function ($table) use ($emit) {
|
|
$emit(array('stage' => 'rollback', 'progress' => 96, 'title' => 'Возвращаем исходную базу', 'detail' => $table, 'table' => $table));
|
|
});
|
|
self::clearRuntimeCaches();
|
|
$rollbackOk = true;
|
|
} catch (\Throwable $rollbackError) {
|
|
error_log('Database backup rollback failed: ' . $rollbackError->getMessage());
|
|
}
|
|
}
|
|
|
|
error_log('Database backup restore failed: ' . $e->getMessage());
|
|
$message = $rollbackOk
|
|
? 'Восстановление не выполнено: ' . $originalMessage . '. Исходная база возвращена из страховочной копии'
|
|
: 'Восстановление прервано: ' . $originalMessage . '. Автоматический откат не выполнен; используйте страховочный бэкап';
|
|
$emit(array('status' => 'failed', 'stage' => 'failed', 'progress' => 100, 'title' => 'Восстановление остановлено', 'detail' => $message, 'error' => $message));
|
|
throw new \RuntimeException($message);
|
|
} finally {
|
|
flock($lock, LOCK_UN);
|
|
fclose($lock);
|
|
}
|
|
|
|
$result = array(
|
|
'name' => $summary['name'],
|
|
'tables' => $summary['table_count'],
|
|
'rows' => $summary['inserts'],
|
|
'safety_backup' => isset($safety['name']) ? $safety['name'] : '',
|
|
);
|
|
$emit(array('status' => 'completed', 'stage' => 'complete', 'progress' => 100, 'title' => 'База восстановлена', 'detail' => 'Все таблицы импортированы и кеш очищен', 'result' => $result));
|
|
|
|
return $result;
|
|
}
|
|
|
|
protected static function apply($path, $expectedTables = 0, callable $progress = null)
|
|
{
|
|
if ($path === null || !is_file($path)) {
|
|
throw new \RuntimeException('Файл резервной копии не найден');
|
|
}
|
|
|
|
$mysqli = DB::mysqli();
|
|
$insertBatch = array();
|
|
$insertBytes = 0;
|
|
$insertTable = '';
|
|
$currentTable = '';
|
|
$enumFallbacks = array();
|
|
$tableIndex = 0;
|
|
$statements = 0;
|
|
$flush = function () use ($mysqli, &$insertBatch, &$insertBytes, &$insertTable) {
|
|
if ($insertBatch) { self::executeInsertBatch($mysqli, $insertBatch, $insertTable); }
|
|
$insertBatch = array();
|
|
$insertBytes = 0;
|
|
$insertTable = '';
|
|
};
|
|
SqlDumpReader::scan($path, function ($statement, $info) use ($mysqli, &$insertBatch, &$insertBytes, &$insertTable, &$currentTable, &$enumFallbacks, &$tableIndex, &$statements, $expectedTables, $progress, $flush) {
|
|
$table = isset($info['table']) ? (string) $info['table'] : '';
|
|
if ($table !== '' && $table !== $currentTable) {
|
|
$flush();
|
|
$currentTable = $table;
|
|
$enumFallbacks = array();
|
|
$tableIndex++;
|
|
$statements = 0;
|
|
}
|
|
|
|
$statements++;
|
|
if ($progress && $table !== '' && ($statements === 1 || $statements % 500 === 0)) {
|
|
call_user_func($progress, $table, $tableIndex, max($tableIndex, (int) $expectedTables), $statements);
|
|
}
|
|
|
|
if ($info['type'] === 'create') {
|
|
$enumFallbacks = SqlDumpReader::enumFallbacks($statement);
|
|
} elseif ($info['type'] === 'insert') {
|
|
$statement = SqlDumpReader::normalizeEnumInsert($statement, $enumFallbacks);
|
|
$insertTable = $table;
|
|
$insertBatch[] = $statement;
|
|
$insertBytes += strlen($statement);
|
|
if (count($insertBatch) >= 200 || $insertBytes >= 2097152) { $flush(); }
|
|
return;
|
|
}
|
|
|
|
$flush();
|
|
if (!$mysqli->query($statement)) {
|
|
throw new \RuntimeException(($table !== '' ? 'таблица ' . $table . ': ' : '') . $mysqli->error);
|
|
}
|
|
});
|
|
$flush();
|
|
}
|
|
|
|
protected static function executeInsertBatch($mysqli, array $statements, $table = '')
|
|
{
|
|
if (count($statements) === 1) {
|
|
if (!$mysqli->query($statements[0])) {
|
|
throw new \RuntimeException(($table !== '' ? 'таблица ' . $table . ': ' : '') . $mysqli->error);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$mysqli->multi_query(implode(";\n", $statements))) {
|
|
throw new \RuntimeException(($table !== '' ? 'таблица ' . $table . ': ' : '') . $mysqli->error);
|
|
}
|
|
|
|
do {
|
|
$result = $mysqli->store_result();
|
|
if ($result) { $result->free(); }
|
|
if (!$mysqli->more_results()) { break; }
|
|
} while ($mysqli->next_result());
|
|
|
|
if ($mysqli->errno) {
|
|
throw new \RuntimeException(($table !== '' ? 'таблица ' . $table . ': ' : '') . $mysqli->error);
|
|
}
|
|
}
|
|
|
|
/** Удаляет текущий набор, чтобы восстановление не оставляло таблицы из другой версии. */
|
|
protected static function dropCurrentTables(callable $progress = null)
|
|
{
|
|
$mysqli = DB::mysqli();
|
|
if (!$mysqli->query('SET FOREIGN_KEY_CHECKS=0')) {
|
|
throw new \RuntimeException('Не удалось отключить проверку внешних ключей');
|
|
}
|
|
|
|
try {
|
|
$tables = Model::names();
|
|
$total = count($tables);
|
|
foreach ($tables as $index => $table) {
|
|
if (!$mysqli->query('DROP TABLE IF EXISTS `' . $table . '`')) {
|
|
throw new \RuntimeException('Не удалось подготовить текущую схему к восстановлению');
|
|
}
|
|
|
|
if ($progress) { call_user_func($progress, $table, $index + 1, $total); }
|
|
}
|
|
} finally {
|
|
$mysqli->query('SET FOREIGN_KEY_CHECKS=1');
|
|
}
|
|
}
|
|
|
|
protected static function token(array $summary)
|
|
{
|
|
$payload = implode('|', array(
|
|
$summary['name'],
|
|
$summary['hash'],
|
|
$summary['size'],
|
|
isset($summary['mtime']) ? $summary['mtime'] : 0,
|
|
$summary['current_database'],
|
|
$summary['current_prefixes'],
|
|
));
|
|
|
|
return hash_hmac('sha256', $payload, (string) Session::csrfToken());
|
|
}
|
|
|
|
protected static function warnings(array $summary)
|
|
{
|
|
$warnings = array();
|
|
if ($summary['database'] !== '' && $summary['database'] !== $summary['current_database']) {
|
|
$warnings[] = 'Дамп создан в базе «' . $summary['database'] . '», текущая база — «' . $summary['current_database'] . '».';
|
|
}
|
|
|
|
if ($summary['prefixes'] !== '' && $summary['prefixes'] !== '{{prefix}}'
|
|
&& $summary['prefixes'] !== $summary['current_prefixes']) {
|
|
$warnings[] = 'Префикс в заголовке отличается от текущего; фактические имена таблиц уже проверены.';
|
|
}
|
|
|
|
return $warnings;
|
|
}
|
|
|
|
protected static function clearRuntimeCaches()
|
|
{
|
|
Cache::flush();
|
|
CompiledModuleRegistry::invalidate();
|
|
Twig::resetInstance();
|
|
foreach (array('sql', 'twig', 'tpl', 'content-snapshots', 'module', 'feeds') as $directory) {
|
|
self::removeContents(BASEPATH . DS . 'tmp' . DS . 'cache' . DS . $directory);
|
|
}
|
|
}
|
|
|
|
protected static function jobRoot()
|
|
{
|
|
$root = BASEPATH . DS . 'storage' . DS . 'database-restore' . DS . 'jobs';
|
|
if (!is_dir($root) && !@mkdir($root, 0750, true) && !is_dir($root)) {
|
|
throw new \RuntimeException('Не удалось создать каталог заданий восстановления');
|
|
}
|
|
|
|
return $root;
|
|
}
|
|
|
|
protected static function jobFile($id)
|
|
{
|
|
$id = trim((string) $id);
|
|
if (!preg_match('/^[0-9]{14}-[a-f0-9]{12}$/', $id)) {
|
|
throw new \InvalidArgumentException('Некорректный идентификатор восстановления');
|
|
}
|
|
|
|
return self::jobRoot() . DS . $id . '.json';
|
|
}
|
|
|
|
protected static function loadJob($id)
|
|
{
|
|
$file = self::jobFile($id);
|
|
$job = is_file($file) ? json_decode((string) file_get_contents($file), true) : null;
|
|
if (!is_array($job) || empty($job['id'])) { throw new \RuntimeException('Задание восстановления не найдено'); }
|
|
return $job;
|
|
}
|
|
|
|
protected static function saveJob(array $job)
|
|
{
|
|
$file = self::jobFile($job['id']);
|
|
$temporary = $file . '.tmp-' . bin2hex(random_bytes(4));
|
|
$content = json_encode($job, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
|
|
if (file_put_contents($temporary, $content, LOCK_EX) === false || !@rename($temporary, $file)) {
|
|
@unlink($temporary);
|
|
throw new \RuntimeException('Не удалось сохранить состояние восстановления');
|
|
}
|
|
|
|
@chmod($file, 0600);
|
|
}
|
|
|
|
protected static function removeContents($directory)
|
|
{
|
|
if (!is_dir($directory)) { return; }
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::CHILD_FIRST
|
|
);
|
|
foreach ($iterator as $item) {
|
|
$item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
|
}
|
|
}
|
|
}
|