| @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' => strpos($f, 'adminx_') === 0, ); } 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('adminx_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('adminx_' . ($purpose !== '' ? $purpose . '_' : '') . implode('-', $prefixes) . '_' . date('Ymd_His'), '.sql.gz'); $path = $dir . DS . $name; $temporary = $path . '.part'; $lockPath = $dir . DS . '.adminx-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, "-- adminx 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); } 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); } } }