diff --git a/app/Console/Commands/BackupDatabase.php b/app/Console/Commands/BackupDatabase.php new file mode 100644 index 0000000..be93ec1 --- /dev/null +++ b/app/Console/Commands/BackupDatabase.php @@ -0,0 +1,159 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Admin/BackupController.php b/app/Http/Controllers/Admin/BackupController.php new file mode 100644 index 0000000..ada7056 --- /dev/null +++ b/app/Http/Controllers/Admin/BackupController.php @@ -0,0 +1,284 @@ +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]; + } +} diff --git a/resources/views/admin/backups/index.blade.php b/resources/views/admin/backups/index.blade.php new file mode 100644 index 0000000..c12a57b --- /dev/null +++ b/resources/views/admin/backups/index.blade.php @@ -0,0 +1,168 @@ + + + + + +
+ + +
+ Espacio en Copias +
+ {{ $diskStats['total_backups_size'] }} +
+

Total acumulado de archivos .zip

+
+ + +
+ Espacio Libre del Servidor +
+ {{ $diskStats['free_space'] }} +
+

De un total de {{ $diskStats['total_space'] }}

+
+ + +
+
+ Capacidad de Almacenamiento +
+ Libre: {{ $diskStats['free_percentage'] }}% + Usado: {{ 100 - $diskStats['free_percentage'] }}% +
+
+
+
+
+
+
+ + +
+ + +
+
+ @csrf +
+ + +
+ +
+
+ + +
+
+ @csrf + +
+
+
+ + +
+ + + + + + + + + + + @forelse($backups as $backup) + + + + + + + @empty + + + + @endforelse + +
Nombre del ArchivoFecha de CreaciónTamañoAcciones
+
+ + + + + + {{ $backup['filename'] }} + +
+
+ + {{ $backup['created_at'] }} + + + {{ $backup['size'] }} + + +
+ @csrf + +
+ + + + + + + +
+ @csrf + @method('DELETE') + +
+
+
+ + + +

No se han generado copias de seguridad de la base de datos todavía.

+

Haga clic en "Generar Nueva Copia" para iniciar una.

+
+
+
+ + + @push('scripts') + + @endpush + +
diff --git a/resources/views/components/taller-card.blade.php b/resources/views/components/taller-card.blade.php index 239a7e7..85cb378 100644 --- a/resources/views/components/taller-card.blade.php +++ b/resources/views/components/taller-card.blade.php @@ -2,10 +2,10 @@ @php $borderColor = match($color) { - 'red' => 'border-l-red-500', - 'yellow' => 'border-l-yellow-500', - 'neon' => 'border-l-neon-lime', - default => 'border-l-gray-500' + 'red' => 'border-l-red-500 dark:border-l-red-500', + 'yellow' => 'border-l-yellow-500 dark:border-l-yellow-500', + 'neon' => 'border-l-neon-lime dark:border-l-neon-lime', + default => 'border-l-gray-500 dark:border-l-gray-500' }; @endphp diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 0db80de..1372d8b 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -51,6 +51,15 @@ + @if(auth()->user()->role === 'admin') + + + + + + + @endif + diff --git a/resources/views/login.blade.php b/resources/views/login.blade.php index 1cc3e0d..a9a728d 100644 --- a/resources/views/login.blade.php +++ b/resources/views/login.blade.php @@ -33,12 +33,30 @@ - @endif
- +
+ + +
@@ -46,7 +64,6 @@
-
+ class="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-700 dark:hover:text-white transition-colors"> + {{-- Ojo abierto --}} + + + + + {{-- Ojo cerrado --}} + +
diff --git a/resources/views/taller/index.blade.php b/resources/views/taller/index.blade.php index de13893..51c01ec 100644 --- a/resources/views/taller/index.blade.php +++ b/resources/views/taller/index.blade.php @@ -1,8 +1,6 @@ - -
-
- - -
-
-

🔴 Pendientes / A Revisar

- {{ $pending->count() }} + +
+ +
+

+ 🔴 Pendientes / A Revisar +

+ + {{ $pending->count() }} +
+
@foreach($pending as $job) @@ -35,12 +39,22 @@
- -
-
-

🟡 En Reparación

- {{ $inProgress->count() }} + +
+ +
+

+ 🟡 En Reparación +

+ + {{ $inProgress->count() }} +
+
@foreach($inProgress as $job) @@ -48,12 +62,22 @@
- -
-
-

🟢 Listas para Retirar

- {{ $ready->count() }} + +
+ +
+

+ 🟢 Listas para Retirar +

+ + {{ $ready->count() }} +
+
@foreach($ready as $job) @@ -63,50 +87,4 @@
-
- -
-

- 🟡 En Reparación -

- - {{ $inProgress->count() }} - -
- -
- @foreach($inProgress as $job) - - @endforeach -
-
- -
- -
-

- 🟢 Listas para Retirar -

- - {{ $ready->count() }} - -
- -
- @foreach($ready as $job) - - @endforeach -
-
- -
- \ No newline at end of file diff --git a/routes/console.php b/routes/console.php index 3c9adf1..2cef2f6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('db:backup')->weekly(); diff --git a/routes/web.php b/routes/web.php index 118f43b..79b1e15 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,7 @@ use App\Http\Controllers\AppointmentController; use App\Http\Controllers\SupplierController; use App\Http\Controllers\PasswordResetLinkController; use App\Http\Controllers\NewPasswordController; +use App\Http\Controllers\Admin\BackupController; use App\Models\Product; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Session; @@ -75,4 +76,14 @@ Route::middleware(['auth'])->group(function () { Route::get('/appointments/{appointment}', [AppointmentController::class, 'show']) ->name('appointments.show'); Route::view('/faq', 'faq')->name('faq'); + + // Copias de Seguridad (Solo Administradores) + Route::middleware(['admin'])->prefix('admin')->name('admin.')->group(function () { + Route::get('/backups', [BackupController::class, 'index'])->name('backups.index'); + Route::post('/backups/create', [BackupController::class, 'create'])->name('backups.create'); + Route::post('/backups/upload', [BackupController::class, 'upload'])->name('backups.upload'); + Route::post('/backups/{filename}/restore', [BackupController::class, 'restore'])->name('backups.restore'); + Route::get('/backups/{filename}/download', [BackupController::class, 'download'])->name('backups.download'); + Route::delete('/backups/{filename}', [BackupController::class, 'destroy'])->name('backups.destroy'); + }); });