mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-01 00:45:44 +00:00
311 lines
9.8 KiB
PHP
311 lines
9.8 KiB
PHP
<?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); }
|
||
}
|
||
}
|