Backup y detalles minimos
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use ZipArchive;
|
||||
|
||||
class BackupDatabase extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'db:backup';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Crea una copia de seguridad comprimida de la base de datos MySQL y elimina respaldos antiguos.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Iniciando copia de seguridad de la base de datos...');
|
||||
|
||||
$dbConfig = config('database.connections.mysql');
|
||||
|
||||
if (!$dbConfig) {
|
||||
$this->error('No se pudo cargar la configuración de la base de datos MySQL.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$host = $dbConfig['host'] ?? '127.0.0.1';
|
||||
$port = $dbConfig['port'] ?? '3306';
|
||||
$database = $dbConfig['database'] ?? 'lauck';
|
||||
$username = $dbConfig['username'] ?? 'root';
|
||||
$password = $dbConfig['password'] ?? '';
|
||||
|
||||
// Asegurarse de que el directorio de respaldos exista
|
||||
$backupDir = storage_path('app/backups');
|
||||
if (!File::exists($backupDir)) {
|
||||
File::makeDirectory($backupDir, 0755, true);
|
||||
}
|
||||
|
||||
// Nombre de los archivos
|
||||
$timestamp = now()->format('Y-m-d_H-i-s');
|
||||
$sqlFile = "backup-{$database}-{$timestamp}.sql";
|
||||
$sqlPath = $backupDir . '/' . $sqlFile;
|
||||
$zipFile = "backup-{$database}-{$timestamp}.zip";
|
||||
$zipPath = $backupDir . '/' . $zipFile;
|
||||
|
||||
// Buscar el ejecutable mysqldump
|
||||
$mysqldumpPath = 'mysqldump';
|
||||
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
||||
// En Windows/XAMPP comúnmente está en C:\xampp\mysql\bin\mysqldump.exe
|
||||
$xamppPath = 'C:\\xampp\\mysql\\bin\\mysqldump.exe';
|
||||
if (File::exists($xamppPath)) {
|
||||
$mysqldumpPath = $xamppPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Ejecutar mysqldump con Process
|
||||
// Usamos variables de entorno para la contraseña por seguridad
|
||||
$command = [
|
||||
$mysqldumpPath,
|
||||
"--host={$host}",
|
||||
"--port={$port}",
|
||||
"--user={$username}",
|
||||
"--no-tablespaces",
|
||||
$database
|
||||
];
|
||||
|
||||
// Inherit SystemRoot and PATH from the system environment to prevent Winsock 10106 errors on Windows
|
||||
$env = [
|
||||
'SystemRoot' => getenv('SystemRoot') ?: 'C:\\Windows',
|
||||
'windir' => getenv('windir') ?: 'C:\\Windows',
|
||||
'PATH' => getenv('PATH'),
|
||||
];
|
||||
if ($password !== '') {
|
||||
$env['MYSQL_PWD'] = $password;
|
||||
}
|
||||
|
||||
$this->info("Ejecutando volcado a archivo temporal SQL...");
|
||||
|
||||
$process = new Process($command, null, $env);
|
||||
$process->setTimeout(300); // 5 minutos máximo
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new \Exception(trim($process->getErrorOutput()) ?: 'Error desconocido al ejecutar mysqldump.');
|
||||
}
|
||||
|
||||
// Guardar salida en el archivo SQL
|
||||
File::put($sqlPath, $process->getOutput());
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Error durante el volcado de la base de datos: ' . $e->getMessage());
|
||||
// Limpieza si quedó el archivo a medio hacer
|
||||
if (File::exists($sqlPath)) {
|
||||
File::delete($sqlPath);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Comprimir el archivo SQL a ZIP
|
||||
$this->info('Comprimiendo respaldo...');
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE) === true) {
|
||||
$zip->addFile($sqlPath, $sqlFile);
|
||||
$zip->close();
|
||||
|
||||
// Eliminar el archivo SQL temporal
|
||||
File::delete($sqlPath);
|
||||
$this->info("Copia de seguridad guardada con éxito en: {$zipFile}");
|
||||
} else {
|
||||
$this->error('No se pudo crear el archivo ZIP.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Retención de respaldos (limpieza de archivos antiguos)
|
||||
$this->cleanupOldBackups($backupDir);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina respaldos antiguos que exceden el límite de días (30 días).
|
||||
*/
|
||||
protected function cleanupOldBackups($backupDir)
|
||||
{
|
||||
$this->info('Revisando si hay respaldos antiguos para limpiar...');
|
||||
$days = 30; // Conservar los últimos 30 días de respaldos semanales
|
||||
$files = File::files($backupDir);
|
||||
|
||||
foreach ($files as $file) {
|
||||
// Verificar que sea un archivo de backup
|
||||
if ($file->getExtension() === 'zip' && strpos($file->getFilename(), 'backup-') === 0) {
|
||||
$lastModified = $file->getMTime();
|
||||
$ageInDays = (time() - $lastModified) / (24 * 60 * 60);
|
||||
|
||||
if ($ageInDays > $days) {
|
||||
$this->warn("Eliminando respaldo antiguo: " . $file->getFilename());
|
||||
File::delete($file->getPathname());
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->info('Limpieza finalizada.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class BackupController extends Controller
|
||||
{
|
||||
/**
|
||||
* Directorio de respaldos.
|
||||
*/
|
||||
protected $backupDir;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->backupDir = storage_path('app/backups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra el panel con el listado de copias de seguridad.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// Asegurarse de que el directorio exista
|
||||
if (!File::exists($this->backupDir)) {
|
||||
File::makeDirectory($this->backupDir, 0755, true);
|
||||
}
|
||||
|
||||
$files = File::files($this->backupDir);
|
||||
$backups = [];
|
||||
$totalSize = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file->getExtension() === 'zip' && strpos($file->getFilename(), 'backup-') === 0) {
|
||||
$size = $file->getSize();
|
||||
$totalSize += $size;
|
||||
|
||||
$backups[] = [
|
||||
'filename' => $file->getFilename(),
|
||||
'size' => $this->formatBytes($size),
|
||||
'raw_size' => $size,
|
||||
'created_at' => \Carbon\Carbon::createFromTimestamp($file->getMTime())->format('d/m/Y H:i:s'),
|
||||
'mtime' => $file->getMTime(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Ordenar de más nuevo a más viejo
|
||||
usort($backups, function ($a, $b) {
|
||||
return $b['mtime'] <=> $a['mtime'];
|
||||
});
|
||||
|
||||
// Estadísticas de disco
|
||||
$freeDiskSpace = disk_free_space($this->backupDir);
|
||||
$totalDiskSpace = disk_total_space($this->backupDir);
|
||||
|
||||
$diskStats = [
|
||||
'total_backups_size' => $this->formatBytes($totalSize),
|
||||
'free_space' => $this->formatBytes($freeDiskSpace),
|
||||
'total_space' => $this->formatBytes($totalDiskSpace),
|
||||
'free_percentage' => round(($freeDiskSpace / $totalDiskSpace) * 100, 1),
|
||||
];
|
||||
|
||||
return view('admin.backups.index', compact('backups', 'diskStats'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
try {
|
||||
$exitCode = Artisan::call('db:backup');
|
||||
$output = Artisan::output();
|
||||
|
||||
if ($exitCode === 0) {
|
||||
return redirect()->route('admin.backups.index')
|
||||
->with('success', 'Copia de seguridad generada correctamente.');
|
||||
} else {
|
||||
\Illuminate\Support\Facades\Log::error("Artisan db:backup failed with exit code {$exitCode}. Output: " . $output);
|
||||
return redirect()->route('admin.backups.index')
|
||||
->with('error', 'Ocurrió un error al generar la copia de seguridad. Detalles: ' . trim($output));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::error("Artisan db:backup threw exception: " . $e->getMessage());
|
||||
return redirect()->route('admin.backups.index')
|
||||
->with('error', 'Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Descargar una copia de seguridad.
|
||||
*/
|
||||
public function download($filename)
|
||||
{
|
||||
// Validación de seguridad para evitar directory traversal
|
||||
if (strpos($filename, '..') !== false || strpos($filename, '/') !== false || strpos($filename, '\\') !== false) {
|
||||
abort(404, 'Nombre de archivo no válido.');
|
||||
}
|
||||
|
||||
$path = $this->backupDir . '/' . $filename;
|
||||
|
||||
if (!File::exists($path)) {
|
||||
abort(404, 'El archivo no existe.');
|
||||
}
|
||||
|
||||
return response()->download($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar una copia de seguridad.
|
||||
*/
|
||||
public function destroy($filename)
|
||||
{
|
||||
// Validación de seguridad para evitar directory traversal
|
||||
if (strpos($filename, '..') !== false || strpos($filename, '/') !== false || strpos($filename, '\\') !== false) {
|
||||
abort(400, 'Nombre de archivo no válido.');
|
||||
}
|
||||
|
||||
$path = $this->backupDir . '/' . $filename;
|
||||
|
||||
if (File::exists($path)) {
|
||||
File::delete($path);
|
||||
return redirect()->route('admin.backups.index')
|
||||
->with('success', 'Copia de seguridad eliminada correctamente.');
|
||||
}
|
||||
|
||||
return redirect()->route('admin.backups.index')
|
||||
->with('error', 'No se encontró el archivo a eliminar.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subir un archivo de copia de seguridad (.zip).
|
||||
*/
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'backup_file' => 'required|file|mimes:zip|max:50000', // 50MB max
|
||||
]);
|
||||
|
||||
$file = $request->file('backup_file');
|
||||
$filename = $file->getClientOriginalName();
|
||||
|
||||
// Validación de seguridad para el nombre del archivo
|
||||
if (strpos($filename, 'backup-') !== 0 || $file->getClientOriginalExtension() !== 'zip') {
|
||||
return redirect()->route('admin.backups.index')
|
||||
->with('error', 'El archivo debe ser un .zip válido de respaldo (su nombre debe empezar con "backup-").');
|
||||
}
|
||||
|
||||
// Asegurarse de que el directorio exista
|
||||
if (!File::exists($this->backupDir)) {
|
||||
File::makeDirectory($this->backupDir, 0755, true);
|
||||
}
|
||||
|
||||
$file->move($this->backupDir, $filename);
|
||||
|
||||
return redirect()->route('admin.backups.index')
|
||||
->with('success', 'Copia de seguridad subida correctamente.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaurar la base de datos a partir de una copia de seguridad.
|
||||
*/
|
||||
public function restore($filename)
|
||||
{
|
||||
// Validación de seguridad para evitar directory traversal
|
||||
if (strpos($filename, '..') !== false || strpos($filename, '/') !== false || strpos($filename, '\\') !== false) {
|
||||
abort(400, 'Nombre de archivo no válido.');
|
||||
}
|
||||
|
||||
$zipPath = $this->backupDir . '/' . $filename;
|
||||
|
||||
if (!File::exists($zipPath)) {
|
||||
return redirect()->route('admin.backups.index')->with('error', 'El archivo no existe.');
|
||||
}
|
||||
|
||||
// Extraer el archivo SQL del ZIP
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath) === true) {
|
||||
// Buscamos el primer archivo SQL dentro del zip
|
||||
$sqlFilename = null;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
if (pathinfo($stat['name'], PATHINFO_EXTENSION) === 'sql') {
|
||||
$sqlFilename = $stat['name'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$sqlFilename) {
|
||||
$zip->close();
|
||||
return redirect()->route('admin.backups.index')->with('error', 'No se encontró ningún archivo SQL en el archivo comprimido.');
|
||||
}
|
||||
|
||||
// Extraer el SQL a un directorio temporal de backups
|
||||
$zip->extractTo($this->backupDir, $sqlFilename);
|
||||
$zip->close();
|
||||
|
||||
$sqlPath = $this->backupDir . '/' . $sqlFilename;
|
||||
|
||||
// Datos de conexión
|
||||
$dbConfig = config('database.connections.mysql');
|
||||
$host = $dbConfig['host'] ?? '127.0.0.1';
|
||||
$port = $dbConfig['port'] ?? '3306';
|
||||
$database = $dbConfig['database'] ?? 'lauck';
|
||||
$username = $dbConfig['username'] ?? 'root';
|
||||
$password = $dbConfig['password'] ?? '';
|
||||
|
||||
// Ejecutable de mysql
|
||||
$mysqlPath = 'mysql';
|
||||
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
||||
$xamppPath = 'C:\\xampp\\mysql\\bin\\mysql.exe';
|
||||
if (File::exists($xamppPath)) {
|
||||
$mysqlPath = $xamppPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Ejecutar importación usando Process y redirección de entrada de archivo
|
||||
$command = [
|
||||
$mysqlPath,
|
||||
"--host={$host}",
|
||||
"--port={$port}",
|
||||
"--user={$username}",
|
||||
$database
|
||||
];
|
||||
|
||||
$env = [
|
||||
'SystemRoot' => getenv('SystemRoot') ?: 'C:\\Windows',
|
||||
'windir' => getenv('windir') ?: 'C:\\Windows',
|
||||
'PATH' => getenv('PATH'),
|
||||
];
|
||||
if ($password !== '') {
|
||||
$env['MYSQL_PWD'] = $password;
|
||||
}
|
||||
|
||||
$process = new \Symfony\Component\Process\Process($command, null, $env);
|
||||
|
||||
// Pasar el contenido del archivo SQL a la entrada estándar (stdin)
|
||||
$process->setInput(File::get($sqlPath));
|
||||
$process->setTimeout(300);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
|
||||
// Limpiar archivo SQL temporal
|
||||
File::delete($sqlPath);
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new \Exception(trim($process->getErrorOutput()) ?: 'Error desconocido al restaurar el respaldo.');
|
||||
}
|
||||
|
||||
return redirect()->route('admin.backups.index')
|
||||
->with('success', 'Base de datos restaurada correctamente a partir del respaldo: ' . $filename);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
if (File::exists($sqlPath)) {
|
||||
File::delete($sqlPath);
|
||||
}
|
||||
\Illuminate\Support\Facades\Log::error("Backup restore failed: " . $e->getMessage());
|
||||
return redirect()->route('admin.backups.index')
|
||||
->with('error', 'Error al restaurar base de datos: ' . $e->getMessage());
|
||||
}
|
||||
} else {
|
||||
return redirect()->route('admin.backups.index')->with('error', 'No se pudo abrir el archivo ZIP.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatear bytes a tamaño legible.
|
||||
*/
|
||||
private function formatBytes($bytes, $precision = 2)
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
|
||||
$bytes /= pow(1024, $pow);
|
||||
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user