Files
ave-cms/adminx/modules/Database/Backup.php
T
2026-07-30 11:56:32 +03:00

311 lines
9.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Database/Backup.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 DB;
/**
* Резервные копии БД: создание gzip-дампа единой рабочей схемы,
* путь и удаление. Дампы лежат в tmp/backup. Имена файлов строго валидируются,
* выход за пределы каталога исключён.
*/
class Backup
{
public static function dir()
{
return BASEPATH . DS . 'tmp' . DS . 'backup';
}
/** Список файлов резервных копий (name/size/mtime), новые сверху. */
public static function all()
{
$dir = self::dir();
if (!is_dir($dir)) {
return array();
}
$out = array();
foreach (scandir($dir) as $f) {
if ($f === '.' || $f === '..' || $f[0] === '.') {
continue;
}
if (!preg_match('/\.(sql|sql\.gz)$/i', $f)) {
continue;
}
$path = $dir . DS . $f;
if (!is_file($path)) {
continue;
}
$out[] = array(
'name' => $f,
'size' => filesize($path),
'size_h' => Model::human(filesize($path)),
'mtime' => filemtime($path),
'own' => self::isManagedName($f),
);
}
usort($out, function ($a, $b) { return $b['mtime'] - $a['mtime']; });
return $out;
}
/** Полный путь к файлу бэкапа по имени (или null, если имя небезопасно/нет файла). */
public static function path($name)
{
$name = basename((string) $name);
if (!preg_match('/^[A-Za-z0-9._-]+\.(sql|sql\.gz)$/', $name)) {
return null;
}
$path = self::dir() . DS . $name;
return is_file($path) ? $path : null;
}
public static function delete($name)
{
$path = self::path($name);
if ($path === null) {
return false;
}
return @unlink($path);
}
/** Принять скачанный ранее дамп и добавить его в список после полной проверки. */
public static function upload(array $file)
{
if (!isset($file['error']) || (int) $file['error'] !== UPLOAD_ERR_OK
|| empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
throw new \RuntimeException('Файл резервной копии не загружен');
}
$size = isset($file['size']) ? (int) $file['size'] : 0;
if ($size <= 0 || $size > BackupRestore::MAX_ARCHIVE_BYTES) {
throw new \RuntimeException('Размер резервной копии недопустим');
}
$original = isset($file['name']) ? (string) $file['name'] : '';
if (preg_match('/\.sql\.gz$/i', $original)) {
$extension = '.sql.gz';
} elseif (preg_match('/\.sql$/i', $original)) {
$extension = '.sql';
} else {
throw new \RuntimeException('Поддерживаются только файлы .sql и .sql.gz');
}
$dir = self::ensureDir();
$name = self::uniqueName('avecms_uploaded_' . date('Ymd_His'), $extension);
$temporary = $dir . DS . '.upload-' . bin2hex(random_bytes(6)) . $extension;
if (!move_uploaded_file($file['tmp_name'], $temporary)) {
throw new \RuntimeException('Не удалось сохранить загруженный файл');
}
try {
$summary = SqlDumpReader::scan($temporary);
$path = $dir . DS . $name;
if (!@rename($temporary, $path)) {
throw new \RuntimeException('Не удалось завершить загрузку резервной копии');
}
} catch (\Throwable $e) {
@unlink($temporary);
throw $e;
}
return array(
'name' => $name,
'size' => filesize($path),
'tables' => $summary['table_count'],
'rows' => $summary['inserts'],
);
}
/**
* Создать gzip-дамп всех таблиц текущего префикса.
* @return array{name:string, size:int, tables:int}
*/
public static function create($purpose = '', callable $progress = null)
{
$dir = self::ensureDir();
$tables = Model::names();
if (!$tables) {
throw new \RuntimeException('Нет таблиц для резервного копирования');
}
$prefixes = Model::prefixes();
$purpose = preg_replace('/[^a-z0-9_-]+/', '', strtolower((string) $purpose));
$name = self::uniqueName('avecms_' . ($purpose !== '' ? $purpose . '_' : '') . implode('-', $prefixes) . '_' . date('Ymd_His'), '.sql.gz');
$path = $dir . DS . $name;
$temporary = $path . '.part';
$lockPath = $dir . DS . '.avecms-backup.lock';
$lock = fopen($lockPath, 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
if ($lock) { fclose($lock); }
throw new \RuntimeException('Другая резервная копия уже создаётся');
}
$fp = gzopen($temporary, 'wb6');
if (!$fp) {
flock($lock, LOCK_UN);
fclose($lock);
throw new \RuntimeException('Не удалось создать файл дампа');
}
gzwrite($fp, "-- AVE.cms full dump\n-- database: " . Model::databaseName() . "\n-- prefixes: {{prefix}}\n-- source-prefix: " . Model::basePrefix() .
"\n-- date: " . date('Y-m-d H:i:s') . "\n\nSET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n");
$mysqli = DB::mysqli();
try {
$total = count($tables);
foreach ($tables as $index => $table) {
self::notify($progress, array(
'table' => $table,
'current' => $index + 1,
'total' => $total,
'rows' => 0,
));
self::dumpTable($fp, $table, $mysqli, $progress, $index + 1, $total);
}
gzwrite($fp, "\nSET FOREIGN_KEY_CHECKS=1;\n");
gzclose($fp);
$fp = null;
if (!@rename($temporary, $path)) {
throw new \RuntimeException('Не удалось завершить файл дампа');
}
} catch (\Throwable $e) {
if ($fp) { gzclose($fp); }
@unlink($temporary);
flock($lock, LOCK_UN);
fclose($lock);
throw $e;
}
flock($lock, LOCK_UN);
fclose($lock);
return array('name' => $name, 'size' => filesize($path), 'tables' => count($tables), 'prefixes' => $prefixes);
}
/** Новые имена AVE.cms и старые adminx-дампы поддерживаются одинаково. */
public static function isManagedName($name)
{
$name = basename((string) $name);
return strpos($name, 'avecms_') === 0 || strpos($name, 'adminx_') === 0;
}
protected static function dumpTable($fp, $table, $mysqli, $progress = null, $current = 0, $total = 0)
{
$portableTable = Model::portableTableName($table);
gzwrite($fp, "\n-- ----- Table `{$portableTable}` -----\nDROP TABLE IF EXISTS `{$portableTable}`;\n");
$create = (array) DB::query('SHOW CREATE TABLE `' . $table . '`')->getObject();
$ddl = isset($create['Create Table']) ? $create['Create Table'] : '';
$enumFallbacks = SqlDumpReader::enumFallbacks($ddl);
if ($ddl !== '') {
gzwrite($fp, self::portableDdl($ddl) . ";\n\n");
}
//-- Данные пакетами, чтобы не держать всё в памяти.
$offset = 0;
$batch = 500;
do {
$rows = DB::query('SELECT * FROM `' . $table . '` LIMIT ' . $batch . ' OFFSET ' . $offset)->getAll();
if (!$rows) {
break;
}
foreach ($rows as $row) {
$row = (array) $row;
$cols = array();
$vals = array();
foreach ($row as $col => $val) {
$cols[] = '`' . $col . '`';
if ($val === '' && isset($enumFallbacks[$col])) {
$val = $enumFallbacks[$col];
}
if ($val === null) {
$vals[] = 'NULL';
} else {
$vals[] = "'" . $mysqli->real_escape_string((string) $val) . "'";
}
}
gzwrite($fp, 'INSERT INTO `' . $portableTable . '` (' . implode(',', $cols) . ') VALUES (' . implode(',', $vals) . ");\n");
}
$offset += $batch;
if ($offset === $batch || $offset % 5000 === 0 || count($rows) < $batch) {
self::notify($progress, array(
'table' => $table,
'current' => (int) $current,
'total' => (int) $total,
'rows' => $offset,
));
}
} while (count($rows) === $batch);
gzwrite($fp, "\n");
}
/** Заменяет префикс только внутри SQL-идентификаторов, не затрагивая данные строк. */
protected static function portableDdl($ddl)
{
$ddl = preg_replace_callback('/`([^`]+)`/', function ($match) {
return '`' . Model::portableTableName($match[1]) . '`';
}, (string) $ddl);
//-- MariaDB выводит DEFAULT NULL у TEXT/BLOB, тогда как MySQL 5.7
//-- запрещает default для этих типов. NULL и без clause остаётся default.
return preg_replace_callback(
'/^(\s*`[^`]+`\s+(?:tiny|medium|long)?(?:text|blob)\b[^,\r\n]*?)\s+DEFAULT\s+NULL(\s*,?)$/mi',
function ($match) { return $match[1] . $match[2]; },
$ddl
);
}
protected static function ensureDir()
{
$dir = self::dir();
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new \RuntimeException('Каталог бэкапов недоступен для записи');
}
return $dir;
}
protected static function uniqueName($base, $extension)
{
$name = (string) $base . (string) $extension;
if (is_file(self::dir() . DS . $name)) {
$name = (string) $base . '_' . bin2hex(random_bytes(3)) . (string) $extension;
}
return $name;
}
protected static function notify($progress, array $payload)
{
if ($progress) { call_user_func($progress, $payload); }
}
}