feat: add app and database modules
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user