Merge branch 'SystemaFull' of https://github.com/BryamE/ProyectoLauck into ReportesNEW

This commit is contained in:
Bryam105
2026-06-02 10:51:28 -03:00
27 changed files with 1265 additions and 215 deletions
+159
View File
@@ -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];
}
}
+56 -4
View File
@@ -70,13 +70,39 @@ class ProductosController extends Controller
'min_stock_alert' => 'required|integer|min:0',
'type' => 'required|in:bike,accessory,clothing,spare,service,children,skate,rollers,other',
'serial_number' => 'nullable|string|max:100',
'image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048',
'image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:20480',
'suppliers_id' => 'required|exists:suppliers,id',
'description' => 'nullable|string'
], [
'image.uploaded' => 'La imagen supera el límite de subida permitido por el servidor.',
'image.max' => 'La imagen no debe pesar más de 20 MB.',
'image.image' => 'El archivo debe ser una imagen válida.',
'image.mimes' => 'La imagen debe tener formato jpeg, png, jpg o webp.',
]);
if ($request->hasFile('image')) {
$path = $request->file('image')->store('products', 'public');
$file = $request->file('image');
// Elevar temporalmente el límite de memoria para procesar la imagen
ini_set('memory_limit', '512M');
// Usar Intervention Image v4 con driver GD para procesar la imagen
$manager = new \Intervention\Image\ImageManager(new \Intervention\Image\Drivers\Gd\Driver());
$image = $manager->decode($file->getRealPath());
// Redimensionar proporcionalmente si excede 1200px
if ($image->width() > 1200 || $image->height() > 1200) {
$image->scale(width: 1200, height: 1200);
}
// Comprimir a JPEG con 80% de calidad
$encoded = $image->encode(new \Intervention\Image\Encoders\JpegEncoder(80));
// Nombre de archivo único
$path = 'products/' . uniqid() . '.jpg';
// Guardar en disco public
Storage::disk('public')->put($path, $encoded->toString());
$validated['image_path'] = $path;
}
unset($validated['image']);
@@ -113,9 +139,14 @@ class ProductosController extends Controller
'stock_quantity' => 'required|integer|min:0',
'min_stock_alert' => 'required|integer|min:0',
'serial_number' => 'nullable|string|max:100',
'image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048',
'image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:20480',
'suppliers_id' => 'required|exists:suppliers,id',
'description' => 'nullable|string'
], [
'image.uploaded' => 'La imagen supera el límite de subida permitido por el servidor.',
'image.max' => 'La imagen no debe pesar más de 20 MB.',
'image.image' => 'El archivo debe ser una imagen válida.',
'image.mimes' => 'La imagen debe tener formato jpeg, png, jpg o webp.',
]);
if ($request->hasFile('image')) {
@@ -123,7 +154,28 @@ class ProductosController extends Controller
Storage::disk('public')->delete($product->image_path);
}
$path = $request->file('image')->store('products', 'public');
$file = $request->file('image');
// Elevar temporalmente el límite de memoria para procesar la imagen
ini_set('memory_limit', '512M');
// Usar Intervention Image v4 con driver GD para procesar la imagen
$manager = new \Intervention\Image\ImageManager(new \Intervention\Image\Drivers\Gd\Driver());
$image = $manager->decode($file->getRealPath());
// Redimensionar proporcionalmente si excede 1200px
if ($image->width() > 1200 || $image->height() > 1200) {
$image->scale(width: 1200, height: 1200);
}
// Comprimir a JPEG con 80% de calidad
$encoded = $image->encode(new \Intervention\Image\Encoders\JpegEncoder(80));
// Nombre de archivo único
$path = 'products/' . uniqid() . '.jpg';
// Guardar en disco public
Storage::disk('public')->put($path, $encoded->toString());
$validated['image_path'] = $path;
}
unset($validated['image']);
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckAdmin //
{
public function handle(Request $request, Closure $next): Response
{
if (auth()->check() && auth()->user()->role === 'admin') {
return $next($request);
}
abort(403, 'No tienes permiso para entrar aquí.');
}
}