mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-01 08:45:44 +00:00
268 lines
12 KiB
PHP
268 lines
12 KiB
PHP
<?php
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------------
|
|
| AVE.cms
|
|
|--------------------------------------------------------------------------------------
|
|
| @package AVE.cms
|
|
| @file system/App/Common/CoreUpdate/DatabaseBackup.php
|
|
| @author AVE.cms <support@ave-cms.ru>
|
|
| @copyright 2007-2026 (c) AVE.cms
|
|
| @link https://ave-cms.ru
|
|
| @version 3.3
|
|
*/
|
|
|
|
namespace App\Common\CoreUpdate;
|
|
|
|
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
|
|
|
use App\Common\PublicConfiguration;
|
|
use App\Common\ModuleMigrator;
|
|
use DB;
|
|
|
|
/** Prefix-scoped, resumable database backup used only by signed core updates. */
|
|
class DatabaseBackup
|
|
{
|
|
const BATCH_SIZE = 500;
|
|
|
|
public static function begin($directory, $tablePatterns = null)
|
|
{
|
|
if (!function_exists('gzopen')) { throw new \RuntimeException('Для резервной копии БД требуется zlib'); }
|
|
$tables = self::tables($tablePatterns);
|
|
if (!is_dir($directory) && !@mkdir($directory, 0750, true) && !is_dir($directory)) { throw new \RuntimeException('Не удалось создать каталог резервной копии БД'); }
|
|
self::writeManifest($directory, array(
|
|
'format' => 'ave-core-db-backup-v1',
|
|
'created_at' => date(DATE_ATOM),
|
|
'complete' => false,
|
|
'scope' => $tablePatterns === null ? 'full' : 'selected',
|
|
'tables' => $tables,
|
|
));
|
|
return $tables;
|
|
}
|
|
|
|
public static function writeSchema($directory, $table)
|
|
{
|
|
self::assertPlannedTable($directory, $table);
|
|
$escaped = self::escapedTable($table);
|
|
$create = DB::query('SHOW CREATE TABLE ' . $escaped)->getAssoc();
|
|
$ddl = isset($create['Create Table']) ? (string) $create['Create Table'] : '';
|
|
if ($ddl === '') { throw new \RuntimeException('Не удалось получить схему таблицы ' . $table); }
|
|
self::writeGzip(self::tableDirectory($directory, $table) . '/000-schema.sql.gz', "DROP TABLE IF EXISTS {$escaped};\n{$ddl};\n");
|
|
}
|
|
|
|
public static function writeRows($directory, $table, $offset, $limit = self::BATCH_SIZE)
|
|
{
|
|
self::assertPlannedTable($directory, $table);
|
|
$offset = max(0, (int) $offset);
|
|
$limit = max(1, min(2000, (int) $limit));
|
|
$escaped = self::escapedTable($table);
|
|
$rows = DB::query('SELECT * FROM ' . $escaped . ' LIMIT ' . $limit . ' OFFSET ' . $offset)->getAll();
|
|
$sql = '';
|
|
foreach ($rows ?: array() as $row) {
|
|
$columns = array();
|
|
$values = array();
|
|
foreach ((array) $row as $column => $value) {
|
|
$columns[] = '`' . str_replace('`', '``', (string) $column) . '`';
|
|
$values[] = $value === null ? 'NULL' : "X'" . bin2hex((string) $value) . "'";
|
|
}
|
|
|
|
$sql .= 'INSERT INTO ' . $escaped . ' (' . implode(',', $columns) . ') VALUES (' . implode(',', $values) . ");\n";
|
|
}
|
|
|
|
$file = self::tableDirectory($directory, $table) . '/rows-' . str_pad((string) $offset, 12, '0', STR_PAD_LEFT) . '.sql.gz';
|
|
self::writeGzip($file, $sql);
|
|
return count($rows ?: array());
|
|
}
|
|
|
|
public static function finish($directory)
|
|
{
|
|
$manifest = self::manifest($directory);
|
|
$manifest['complete'] = true;
|
|
$manifest['completed_at'] = date(DATE_ATOM);
|
|
self::writeManifest($directory, $manifest);
|
|
return $manifest;
|
|
}
|
|
|
|
public static function complete($directory)
|
|
{
|
|
try { $manifest = self::manifest($directory); return !empty($manifest['complete']); }
|
|
catch (\Throwable $e) { return false; }
|
|
}
|
|
|
|
public static function estimatedBytes($tablePatterns = null)
|
|
{
|
|
$planned = array_fill_keys(self::tables($tablePatterns), true);
|
|
$total = 0;
|
|
foreach (DB::query('SHOW TABLE STATUS')->getAll() ?: array() as $row) {
|
|
$name = isset($row['Name']) ? (string) $row['Name'] : '';
|
|
if (!isset($planned[$name])) { continue; }
|
|
$total += max(0, (int) (isset($row['Data_length']) ? $row['Data_length'] : 0));
|
|
$total += max(0, (int) (isset($row['Index_length']) ? $row['Index_length'] : 0));
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
public static function create($directory)
|
|
{
|
|
$tables = self::begin($directory);
|
|
foreach ($tables as $table) {
|
|
self::writeSchema($directory, $table);
|
|
$offset = 0;
|
|
do { $count = self::writeRows($directory, $table, $offset); $offset += self::BATCH_SIZE; } while ($count === self::BATCH_SIZE);
|
|
}
|
|
|
|
self::finish($directory);
|
|
return array('path' => $directory, 'tables' => count($tables), 'size' => self::directorySize($directory));
|
|
}
|
|
|
|
public static function restore($directory)
|
|
{
|
|
$manifest = self::manifest($directory);
|
|
if (empty($manifest['complete'])) { throw new \RuntimeException('Резервная копия БД не завершена'); }
|
|
$mysqli = DB::mysqli();
|
|
$count = 0;
|
|
try {
|
|
if (!$mysqli->query('SET FOREIGN_KEY_CHECKS=0')) { throw new \RuntimeException('Не удалось начать восстановление БД'); }
|
|
foreach ($manifest['tables'] as $table) {
|
|
$files = glob(self::tableDirectory($directory, $table) . '/*.sql.gz') ?: array();
|
|
sort($files, SORT_STRING);
|
|
if (!$files || basename($files[0]) !== '000-schema.sql.gz') { throw new \RuntimeException('Неполная копия таблицы ' . $table); }
|
|
foreach ($files as $file) { $count += self::restoreFile($file, $mysqli); }
|
|
}
|
|
} finally {
|
|
$mysqli->query('SET FOREIGN_KEY_CHECKS=1');
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
protected static function restoreFile($file, $mysqli)
|
|
{
|
|
$handle = gzopen($file, 'rb');
|
|
if (!$handle) { throw new \RuntimeException('Не удалось открыть фрагмент резервной копии'); }
|
|
$statement = '';
|
|
$count = 0;
|
|
try {
|
|
while (!gzeof($handle)) {
|
|
$line = gzgets($handle, 1048576);
|
|
if ($line === false) { break; }
|
|
$statement .= $line;
|
|
if (substr(rtrim($line), -1) !== ';') { continue; }
|
|
if (!$mysqli->query(trim($statement))) { throw new \RuntimeException('Не удалось восстановить БД: ' . $mysqli->error); }
|
|
$count++;
|
|
$statement = '';
|
|
}
|
|
|
|
if (trim($statement) !== '') { throw new \RuntimeException('Фрагмент резервной копии оборван'); }
|
|
} finally { gzclose($handle); }
|
|
return $count;
|
|
}
|
|
|
|
public static function tables($tablePatterns = null)
|
|
{
|
|
if ($tablePatterns !== null) {
|
|
if (!is_array($tablePatterns)) { throw new \InvalidArgumentException('Некорректный список таблиц резервной копии'); }
|
|
return self::selectedTables($tablePatterns);
|
|
}
|
|
|
|
$prefixes = array();
|
|
foreach (array('system', 'content', 'catalog', 'module', 'basket', 'contacts', 'public_user', 'public_shell') as $domain) {
|
|
$prefixes[] = PublicConfiguration::prefix($domain);
|
|
}
|
|
|
|
$prefixes = self::compactPrefixes($prefixes);
|
|
$tables = array();
|
|
foreach ($prefixes as $prefix) {
|
|
$rows = DB::query('SHOW FULL TABLES LIKE %s', $prefix . '\_%')->getAll();
|
|
foreach ($rows ?: array() as $row) {
|
|
$values = array_values((array) $row);
|
|
$name = isset($values[0]) ? (string) $values[0] : '';
|
|
$type = isset($values[1]) ? strtoupper((string) $values[1]) : 'BASE TABLE';
|
|
if ($type !== 'BASE TABLE') { continue; }
|
|
if (preg_match('/^[A-Za-z0-9_]+$/', $name)) { $tables[$name] = $name; }
|
|
}
|
|
}
|
|
|
|
ksort($tables, SORT_STRING);
|
|
return array_values($tables);
|
|
}
|
|
|
|
protected static function selectedTables(array $patterns)
|
|
{
|
|
$tables = array();
|
|
foreach ($patterns as $pattern) {
|
|
$pattern = trim((string) $pattern);
|
|
if (!preg_match('/^\{\{(?:prefix|database_prefix|catalog_prefix|content_prefix|module_prefix|basket_prefix|contacts_prefix|public_shell_prefix|public_user_prefix|system_prefix)\}\}_[A-Za-z0-9_]+$/', $pattern)) {
|
|
throw new \InvalidArgumentException('Некорректный шаблон таблицы резервной копии');
|
|
}
|
|
|
|
$table = ModuleMigrator::expandPrefixes($pattern);
|
|
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
|
|
throw new \InvalidArgumentException('Некорректное имя таблицы резервной копии');
|
|
}
|
|
|
|
$row = DB::query(
|
|
'SELECT TABLE_NAME,TABLE_TYPE FROM information_schema.TABLES'
|
|
. ' WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s LIMIT 1',
|
|
$table
|
|
)->getAssoc();
|
|
if (!$row || strtoupper((string) $row['TABLE_TYPE']) !== 'BASE TABLE') { continue; }
|
|
$tables[$table] = $table;
|
|
}
|
|
|
|
ksort($tables, SORT_STRING);
|
|
return array_values($tables);
|
|
}
|
|
|
|
protected static function compactPrefixes(array $prefixes)
|
|
{
|
|
$prefixes = array_values(array_unique(array_filter(array_map('strval', $prefixes))));
|
|
usort($prefixes, function ($left, $right) { return strlen($left) - strlen($right); });
|
|
$result = array();
|
|
foreach ($prefixes as $prefix) {
|
|
$covered = false;
|
|
foreach ($result as $parent) { if ($prefix === $parent || strpos($prefix, $parent . '_') === 0) { $covered = true; break; } }
|
|
if (!$covered) { $result[] = $prefix; }
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
protected static function manifest($directory)
|
|
{
|
|
$file = rtrim((string) $directory, '/\\') . '/backup.json';
|
|
$data = is_file($file) ? json_decode((string) file_get_contents($file), true) : null;
|
|
if (!is_array($data) || !isset($data['format'], $data['tables']) || $data['format'] !== 'ave-core-db-backup-v1' || !is_array($data['tables'])) { throw new \RuntimeException('Реестр резервной копии БД повреждён'); }
|
|
foreach ($data['tables'] as $table) { if (!preg_match('/^[A-Za-z0-9_]+$/', (string) $table)) { throw new \RuntimeException('Реестр резервной копии содержит небезопасное имя'); } }
|
|
return $data;
|
|
}
|
|
|
|
protected static function writeManifest($directory, array $manifest)
|
|
{
|
|
$file = rtrim((string) $directory, '/\\') . '/backup.json';
|
|
@mkdir(dirname($file), 0750, true);
|
|
$tmp = $file . '.tmp-' . bin2hex(random_bytes(4));
|
|
$content = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
|
if (file_put_contents($tmp, $content, LOCK_EX) === false || !@rename($tmp, $file)) { @unlink($tmp); throw new \RuntimeException('Не удалось сохранить реестр резервной копии'); }
|
|
@chmod($file, 0600);
|
|
}
|
|
|
|
protected static function writeGzip($file, $content)
|
|
{
|
|
@mkdir(dirname($file), 0750, true);
|
|
$tmp = $file . '.part';
|
|
$handle = gzopen($tmp, 'wb6');
|
|
if (!$handle) { throw new \RuntimeException('Не удалось создать фрагмент резервной копии'); }
|
|
$written = gzwrite($handle, (string) $content);
|
|
gzclose($handle);
|
|
if ($written === false || (int) $written !== strlen((string) $content) || !@rename($tmp, $file)) { @unlink($tmp); throw new \RuntimeException('Не удалось завершить фрагмент резервной копии'); }
|
|
@chmod($file, 0600);
|
|
}
|
|
|
|
protected static function assertPlannedTable($directory, $table) { $table = (string) $table; $manifest = self::manifest($directory); if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !in_array($table, $manifest['tables'], true)) { throw new \InvalidArgumentException('Таблица не входит в план резервной копии'); } }
|
|
protected static function escapedTable($table) { if (!preg_match('/^[A-Za-z0-9_]+$/', (string) $table)) { throw new \InvalidArgumentException('Некорректное имя таблицы'); } return '`' . $table . '`'; }
|
|
protected static function tableDirectory($directory, $table) { return rtrim((string) $directory, '/\\') . '/tables/' . (string) $table; }
|
|
protected static function directorySize($directory) { $size = 0; $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS)); foreach ($iterator as $file) { if ($file->isFile()) { $size += $file->getSize(); } } return $size; }
|
|
}
|