feat: add app and database modules

This commit is contained in:
2026-05-21 16:05:11 +07:00
parent 37b7e783f5
commit fad70d096b
212 changed files with 23901 additions and 0 deletions
@@ -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);
}
}