551 lines
21 KiB
PHP
551 lines
21 KiB
PHP
<?php
|
|
|
|
/**
|
|
* ============================================================
|
|
*
|
|
* @project biiproject
|
|
*
|
|
* @author Andika Debi Putra
|
|
*
|
|
* @email andikadebiputra@gmail.com
|
|
*
|
|
* @website https://biiproject.com
|
|
*
|
|
* @copyright Copyright (c) 2026 Andika Debi Putra
|
|
* @license Proprietary - All Rights Reserved
|
|
*
|
|
* @version 1.0.0
|
|
*
|
|
* @created 2026-05-01
|
|
* ============================================================
|
|
*
|
|
* Unauthorized copying, modification, distribution, or use
|
|
* of this file is strictly prohibited without prior written
|
|
* permission from the author.
|
|
* ============================================================
|
|
*/
|
|
|
|
namespace App\Services\System;
|
|
|
|
use App\Models\User;
|
|
use App\Notifications\SystemManagementNotification;
|
|
use App\Services\Monitoring\MonitoringFormatter;
|
|
use App\Services\SystemConfig\SystemConfigService;
|
|
use Exception;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Spatie\Backup\BackupDestination\Backup;
|
|
use Spatie\Backup\BackupDestination\BackupDestinationFactory;
|
|
use Spatie\Backup\Config\Config as SpatieConfig;
|
|
use Spatie\Backup\Notifications\Notifications\BackupHasFailedNotification;
|
|
use Spatie\Backup\Notifications\Notifications\BackupWasSuccessfulNotification;
|
|
use Spatie\Backup\Notifications\Notifications\CleanupHasFailedNotification;
|
|
use Spatie\Backup\Notifications\Notifications\CleanupWasSuccessfulNotification;
|
|
use Spatie\Backup\Notifications\Notifications\HealthyBackupWasFoundNotification;
|
|
use Spatie\Backup\Notifications\Notifications\UnhealthyBackupWasFoundNotification;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
class BackupManagementService
|
|
{
|
|
protected $configService;
|
|
|
|
public function __construct(SystemConfigService $configService)
|
|
{
|
|
$this->configService = $configService;
|
|
}
|
|
|
|
/**
|
|
* Apply database settings to Spatie Backup config at runtime.
|
|
*
|
|
* @param bool $skipNotifications Whether to skip setting up notifications (useful for manual UI backups)
|
|
* @param string|null $driver Optional driver to override the database setting
|
|
*/
|
|
public function applyDynamicConfig(bool $skipNotifications = false, ?string $driver = null)
|
|
{
|
|
$settings = $this->configService->all();
|
|
|
|
// 1. Storage Disks
|
|
$driver = $driver ?? get_setting('backup_db_driver', 'local');
|
|
Config::set('backup.backup.destination.disks', [$driver]);
|
|
Config::set('backup.monitor_backups.0.disks', [$driver]);
|
|
|
|
// Dynamic Cloud Storage Configuration
|
|
if ($driver === 'gdrive') {
|
|
Config::set('filesystems.disks.gdrive.clientId', $settings['gdrive_client_id'] ?? '');
|
|
Config::set('filesystems.disks.gdrive.clientSecret', $settings['gdrive_client_secret'] ?? '');
|
|
Config::set('filesystems.disks.gdrive.refreshToken', $settings['gdrive_refresh_token'] ?? '');
|
|
Config::set('filesystems.disks.gdrive.folder', $settings['gdrive_folder'] ?? 'LaravelBackups');
|
|
}
|
|
|
|
// 2. Cleanup Policy
|
|
$retention = (int) ($settings['backup_db_retention'] ?? 7);
|
|
Config::set('backup.cleanup.default_strategy.keep_all_backups_for_days', $retention);
|
|
|
|
// 3. Encryption
|
|
if (! empty($settings['backup_db_encrypt']) && ! empty($settings['backup_db_encrypt_key'])) {
|
|
Config::set('backup.backup.password', $settings['backup_db_encrypt_key']);
|
|
Config::set('backup.backup.encryption', 'aes256');
|
|
} else {
|
|
Config::set('backup.backup.password', null);
|
|
Config::set('backup.backup.encryption', 'none');
|
|
}
|
|
|
|
// 4. Notifications
|
|
$notifyOn = $settings['backup_db_notify_on'] ?? 'failed';
|
|
$notifyTo = $settings['backup_db_notify_to'] ?? '';
|
|
|
|
$channels = [];
|
|
if (! empty($notifyTo)) {
|
|
if (filter_var($notifyTo, FILTER_VALIDATE_EMAIL)) {
|
|
$channels[] = 'mail';
|
|
Config::set('backup.notifications.mail.to', $notifyTo);
|
|
} elseif (str_starts_with($notifyTo, 'http')) {
|
|
$channels[] = 'webhook';
|
|
Config::set('backup.notifications.notifications.webhook.url', $notifyTo);
|
|
}
|
|
}
|
|
|
|
if ($skipNotifications || $notifyOn === 'none') {
|
|
// Use empty channel arrays per-class (Spatie requires all keys to exist)
|
|
$allClasses = [
|
|
BackupHasFailedNotification::class,
|
|
UnhealthyBackupWasFoundNotification::class,
|
|
CleanupHasFailedNotification::class,
|
|
BackupWasSuccessfulNotification::class,
|
|
HealthyBackupWasFoundNotification::class,
|
|
CleanupWasSuccessfulNotification::class,
|
|
];
|
|
Config::set('backup.notifications.notifications', array_fill_keys($allClasses, []));
|
|
} else {
|
|
$map = [
|
|
'success' => [BackupWasSuccessfulNotification::class],
|
|
'failed' => [BackupHasFailedNotification::class],
|
|
'both' => [
|
|
BackupWasSuccessfulNotification::class,
|
|
BackupHasFailedNotification::class,
|
|
],
|
|
];
|
|
|
|
$classes = $map[$notifyOn] ?? $map['failed'];
|
|
$newNotifs = [];
|
|
foreach ($classes as $class) {
|
|
$newNotifs[$class] = $channels;
|
|
}
|
|
Config::set('backup.notifications.notifications', $newNotifs);
|
|
}
|
|
|
|
// 5. Exclude Tables (Injected via database config)
|
|
$dbDriver = config('database.default');
|
|
if (! empty($settings['backup_db_exclude'])) {
|
|
$excluded = array_map('trim', explode(',', $settings['backup_db_exclude']));
|
|
Config::set("database.connections.{$dbDriver}.dump.exclude_tables", $excluded);
|
|
}
|
|
|
|
// 6. DB-Specific Options (mysqldump / pg_dump)
|
|
if ($dbDriver === 'mysql') {
|
|
$extraOptions = ['--hex-blob'];
|
|
Config::set('database.connections.mysql.dump.add_extra_option', implode(' ', $extraOptions));
|
|
|
|
// Windows/Laragon mysqldump path fix
|
|
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
|
$candidates = ['C:/laragon/bin/mysql/mysql-8.0.30-winx64/bin'];
|
|
$globMatches = glob('C:/laragon/bin/mysql/*/bin', GLOB_ONLYDIR);
|
|
if ($globMatches) {
|
|
$candidates = array_merge($candidates, $globMatches);
|
|
}
|
|
foreach ($candidates as $candidate) {
|
|
if (is_dir($candidate) && file_exists($candidate.'/mysqldump.exe')) {
|
|
Config::set('database.connections.mysql.dump.dump_binary_path', $candidate);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} elseif ($dbDriver === 'pgsql') {
|
|
// Postgres specific dump options
|
|
// Spatie pg_dump usually works well with defaults, but we can add path if needed
|
|
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
|
$globMatches = glob('C:/laragon/bin/postgresql/*/bin', GLOB_ONLYDIR);
|
|
if ($globMatches) {
|
|
Config::set('database.connections.pgsql.dump.dump_binary_path', end($globMatches));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public function getBackupList(bool $forceConfig = true, bool $forceRefresh = false)
|
|
{
|
|
if ($forceConfig) {
|
|
$this->applyDynamicConfig(true); // Don't need notifications just to list files
|
|
}
|
|
|
|
$cacheKey = 'system.backup_list';
|
|
|
|
// Helper function for the scan logic
|
|
$scan = function () {
|
|
$config = SpatieConfig::fromArray(config('backup'));
|
|
$backupDestinations = BackupDestinationFactory::createFromArray($config);
|
|
|
|
$backups = [];
|
|
foreach ($backupDestinations as $destination) {
|
|
foreach ($destination->backups() as $backup) {
|
|
$backups[] = [
|
|
'name' => $backup->path(),
|
|
'size' => $this->formatBytes($backup->sizeInBytes()),
|
|
'storage' => $destination->diskName(),
|
|
'date' => $backup->date()->format('Y-m-d H:i:s'),
|
|
'timestamp' => $backup->date()->timestamp,
|
|
'status' => 'Success',
|
|
];
|
|
}
|
|
}
|
|
|
|
// Sort by date descending
|
|
usort($backups, fn ($a, $b) => $b['timestamp'] <=> $a['timestamp']);
|
|
|
|
return $backups;
|
|
};
|
|
|
|
if ($forceRefresh) {
|
|
$data = $scan();
|
|
Cache::put($cacheKey, $data, 30);
|
|
|
|
return $data;
|
|
}
|
|
|
|
return Cache::remember($cacheKey, 30, $scan);
|
|
}
|
|
|
|
/**
|
|
* Get storage health statistics.
|
|
*/
|
|
public function getStorageStats(?string $driver = null)
|
|
{
|
|
$this->applyDynamicConfig(true, $driver);
|
|
$driver = $driver ?? config('backup.backup.destination.disks.0', 'local');
|
|
|
|
$total = 0;
|
|
$free = 0;
|
|
$used = 0;
|
|
$label = 'Local Disk';
|
|
|
|
try {
|
|
if ($driver === 'local') {
|
|
$total = @disk_total_space(storage_path('app')) ?: 0;
|
|
$free = @disk_free_space(storage_path('app')) ?: 0;
|
|
$used = $total - $free;
|
|
} else {
|
|
// For cloud, we might not get total/free easily depending on API
|
|
// We'll return just the used size and assume a default total for UI progress (e.g., 15GB for GDrive free)
|
|
$backups = $this->getBackupList(forceRefresh: false);
|
|
foreach ($backups as $b) {
|
|
if ($b['storage'] === $driver) {
|
|
$used += $this->parseBytes($b['size']);
|
|
}
|
|
}
|
|
$label = strtoupper($driver);
|
|
|
|
// Estimate total for progress bar visibility
|
|
$total = $driver === 'gdrive' ? (15 * 1024 * 1024 * 1024) : 0;
|
|
$free = max(0, $total - $used);
|
|
}
|
|
} catch (Exception $e) {
|
|
Log::error('Failed to get storage stats: '.$e->getMessage());
|
|
}
|
|
|
|
return [
|
|
'label' => $label,
|
|
'driver' => $driver,
|
|
'total' => $this->formatBytes($total),
|
|
'free' => $this->formatBytes($free),
|
|
'used' => $this->formatBytes($used),
|
|
'percentage' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
|
|
'health' => ($total > 0 && ($free / $total) < 0.1) ? 'danger' : 'success',
|
|
'requirements' => $this->checkRequirements(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check if system requirements (binaries) for current driver are met.
|
|
*/
|
|
public function checkRequirements()
|
|
{
|
|
$dbDriver = config('database.default');
|
|
$binary = $dbDriver === 'pgsql' ? 'pg_dump' : ($dbDriver === 'mysql' ? 'mysqldump' : null);
|
|
|
|
if (! $binary) {
|
|
return ['status' => true];
|
|
}
|
|
|
|
$process = new Process(['which', $binary]);
|
|
$process->run();
|
|
|
|
$missing = ! $process->isSuccessful();
|
|
|
|
return [
|
|
'status' => ! $missing,
|
|
'binary' => $binary,
|
|
'message' => $missing ? __(':bin not found on server. Please install it to enable backups.', ['bin' => $binary]) : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Test connection to a specific storage disk.
|
|
*/
|
|
public function testConnection()
|
|
{
|
|
$this->applyDynamicConfig(true);
|
|
$disk = config('backup.backup.destination.disks.0', 'local');
|
|
|
|
try {
|
|
$backupName = config('backup.backup.name', 'biiproject');
|
|
|
|
// Pre-create directory for Google Drive to avoid reachability check failure
|
|
if ($disk === 'gdrive' && ! Storage::disk($disk)->exists($backupName)) {
|
|
Storage::disk($disk)->makeDirectory($backupName);
|
|
}
|
|
|
|
Storage::disk($disk)->files($backupName);
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => __('Successfully connected to :disk storage.', ['disk' => strtoupper($disk)]),
|
|
];
|
|
} catch (Exception $e) {
|
|
return [
|
|
'success' => false,
|
|
'message' => __('Connection failed: ').$e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
private function parseBytes(string $value): float
|
|
{
|
|
return MonitoringFormatter::parseBytes($value);
|
|
}
|
|
|
|
/**
|
|
* Create a new backup.
|
|
*
|
|
* Uses a child process instead of Artisan::call() to avoid inheriting the
|
|
* broken TCP/IP socket context from php artisan serve on Windows.
|
|
*/
|
|
public function createBackup()
|
|
{
|
|
$phpBinary = PHP_BINARY;
|
|
$artisan = base_path('artisan');
|
|
|
|
// Ensure dynamic config is applied in the current process
|
|
$this->applyDynamicConfig(true);
|
|
|
|
$disk = config('backup.backup.destination.disks.0', 'local');
|
|
$backupName = config('backup.backup.name', 'biiproject');
|
|
|
|
// Pre-create directory for Google Drive to avoid reachability check failure in child process
|
|
if ($disk === 'gdrive') {
|
|
try {
|
|
if (! Storage::disk($disk)->exists($backupName)) {
|
|
Storage::disk($disk)->makeDirectory($backupName);
|
|
}
|
|
} catch (Exception $e) {
|
|
Log::warning('Failed to pre-create GDrive backup directory: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
$dbDriver = config('database.default');
|
|
$binaryPath = config("database.connections.{$dbDriver}.dump.dump_binary_path");
|
|
|
|
$env = getenv();
|
|
|
|
// Prepend binary path to PATH for child process
|
|
if ($binaryPath) {
|
|
$separator = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ? ';' : ':';
|
|
$env['PATH'] = $binaryPath.$separator.($env['PATH'] ?? '');
|
|
}
|
|
|
|
// Add some basic required env vars if missing
|
|
$env['APP_ENV'] = app()->environment();
|
|
$env['APP_KEY'] = config('app.key');
|
|
|
|
$command = array_filter([
|
|
$phpBinary,
|
|
$artisan,
|
|
'backup:run',
|
|
'--only-db',
|
|
'--disable-notifications',
|
|
]);
|
|
|
|
$process = new Process($command, base_path(), $env);
|
|
$process->setTimeout(300); // 5 minutes max
|
|
|
|
Log::info('Backup: starting subprocess — '.implode(' ', $command));
|
|
|
|
$process->run();
|
|
|
|
$stdout = $process->getOutput();
|
|
$stderr = $process->getErrorOutput();
|
|
|
|
Log::info('Backup stdout: '.trim($stdout));
|
|
if ($stderr) {
|
|
Log::warning('Backup stderr: '.trim($stderr));
|
|
}
|
|
|
|
if (! $process->isSuccessful()) {
|
|
$detail = trim($stderr ?: $stdout ?: 'No output captured.');
|
|
|
|
Notification::send(
|
|
User::permission('view notification center')->get(),
|
|
new SystemManagementNotification('Backup Failed', "Database backup failed: {$detail}", 'warning', 'Developer')
|
|
);
|
|
|
|
throw new Exception(
|
|
"Backup failed (exit {$process->getExitCode()}): {$detail}"
|
|
);
|
|
}
|
|
|
|
Notification::send(
|
|
User::permission('view notification center')->get(),
|
|
new SystemManagementNotification('Backup Successful', 'Automated database backup completed successfully.', 'info', 'Developer')
|
|
);
|
|
|
|
// Bust the cache AFTER the file is confirmed written to disk
|
|
Cache::forget('system.backup_list');
|
|
|
|
return $stdout;
|
|
}
|
|
|
|
/**
|
|
* Restore from a backup file.
|
|
*/
|
|
public function restoreBackup($disk, $path)
|
|
{
|
|
$this->applyDynamicConfig();
|
|
|
|
if (! Storage::disk($disk)->exists($path)) {
|
|
throw new Exception("Backup file not found on disk: {$disk}");
|
|
}
|
|
|
|
// 1. Enable Maintenance Mode to prevent data corruption
|
|
Artisan::call('down', [
|
|
'--refresh' => 15,
|
|
'--secret' => 'restore-mode',
|
|
'--render' => 'errors::503',
|
|
]);
|
|
|
|
try {
|
|
// 2. Download/Copy to temp via Stream (Safer for large files)
|
|
$tempPath = storage_path('app/temp_restore.zip');
|
|
$diskStream = Storage::disk($disk)->readStream($path);
|
|
$localStream = fopen($tempPath, 'w+');
|
|
|
|
if (! $diskStream || ! $localStream) {
|
|
throw new Exception('Failed to open streams for restoration.');
|
|
}
|
|
|
|
stream_copy_to_stream($diskStream, $localStream);
|
|
|
|
fclose($localStream);
|
|
if (is_resource($diskStream)) {
|
|
fclose($diskStream);
|
|
}
|
|
|
|
// 3. Extract SQL from Zip (with Zip Slip protection)
|
|
$zip = new \ZipArchive;
|
|
$sqlFile = null;
|
|
$extractBase = realpath(storage_path('app'));
|
|
if ($zip->open($tempPath) === true) {
|
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
|
$filename = $zip->getNameIndex($i);
|
|
if (! str_ends_with($filename, '.sql')) {
|
|
continue;
|
|
}
|
|
// Prevent path traversal
|
|
$resolvedPath = $extractBase.DIRECTORY_SEPARATOR.ltrim($filename, '/\\');
|
|
$realResolved = realpath(dirname($resolvedPath));
|
|
if ($realResolved === false || ! str_starts_with($realResolved, $extractBase)) {
|
|
$zip->close();
|
|
throw new Exception("Invalid path in backup archive: {$filename}");
|
|
}
|
|
$zip->extractTo($extractBase, $filename);
|
|
$sqlFile = $extractBase.DIRECTORY_SEPARATOR.$filename;
|
|
break;
|
|
}
|
|
$zip->close();
|
|
}
|
|
|
|
if (! $sqlFile || ! file_exists($sqlFile)) {
|
|
throw new Exception('Could not find SQL file in the backup archive.');
|
|
}
|
|
|
|
// 4. Execute Restore using array-based Process with Streamed Input
|
|
$dbDriver = config('database.default');
|
|
$dbConfig = config("database.connections.{$dbDriver}");
|
|
$binPath = config("database.connections.{$dbDriver}.dump.dump_binary_path", '');
|
|
$env = array_merge(getenv(), [
|
|
'SystemRoot' => getenv('SystemRoot') ?: 'C:\WINDOWS',
|
|
'SystemDrive' => getenv('SystemDrive') ?: 'C:',
|
|
'windir' => getenv('windir') ?: 'C:\WINDOWS',
|
|
]);
|
|
|
|
if ($dbDriver === 'mysql') {
|
|
$command = [
|
|
$binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'mysql' : 'mysql',
|
|
'--host='.($dbConfig['host'] ?? '127.0.0.1'),
|
|
'--port='.($dbConfig['port'] ?? '3306'),
|
|
'--user='.($dbConfig['username'] ?? ''),
|
|
'--password='.($dbConfig['password'] ?? ''),
|
|
$dbConfig['database'] ?? '',
|
|
];
|
|
} elseif ($dbDriver === 'pgsql') {
|
|
$command = [
|
|
$binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'psql' : 'psql',
|
|
'--host='.($dbConfig['host'] ?? '127.0.0.1'),
|
|
'--port='.($dbConfig['port'] ?? '5432'),
|
|
'--username='.($dbConfig['username'] ?? 'postgres'),
|
|
'--dbname='.($dbConfig['database'] ?? ''),
|
|
];
|
|
$env['PGPASSWORD'] = $dbConfig['password'] ?? '';
|
|
} else {
|
|
throw new Exception('Restore currently only supports mysql or pgsql drivers.');
|
|
}
|
|
|
|
// High-performance streamed restore: open SQL file and pass the handle to Process
|
|
$sqlHandle = fopen($sqlFile, 'r');
|
|
$process = new Process($command, base_path(), $env, $sqlHandle);
|
|
$process->setTimeout(1200); // 20 minutes for very large DBs
|
|
$process->run();
|
|
|
|
if (is_resource($sqlHandle)) {
|
|
fclose($sqlHandle);
|
|
}
|
|
|
|
// Cleanup temp files
|
|
@unlink($tempPath);
|
|
@unlink($sqlFile);
|
|
|
|
if (! $process->isSuccessful()) {
|
|
throw new Exception('Restore execution failed: '.($process->getErrorOutput() ?: $process->getOutput() ?: 'Check logs.'));
|
|
}
|
|
|
|
// 5. Clear Caches & Optimization
|
|
Artisan::call('optimize:clear');
|
|
|
|
return true;
|
|
|
|
} catch (Exception $e) {
|
|
throw $e;
|
|
} finally {
|
|
// 6. Ensure system comes back UP even if restore failed
|
|
Artisan::call('up');
|
|
}
|
|
}
|
|
|
|
protected function formatBytes(int|float $bytes, int $precision = 2): string
|
|
{
|
|
return MonitoringFormatter::bytes($bytes, $precision);
|
|
}
|
|
}
|