Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,7 @@ mcp.json
/vendor-backup

.env.testing
nul
nul

# Agent
/.qwen
231 changes: 192 additions & 39 deletions app/Http/Controllers/Setting/PengaturanDatabaseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,28 @@ public function createBackup()
{
try {
Log::info('Starting backup process.');

Artisan::call('backup:run');

Log::info('Backup command output: ' . Artisan::output());

$exitCode = Artisan::call('backup:run');
$output = Artisan::output();

Log::info('Backup command output: ' . $output);
Log::info('Backup exit code: ' . $exitCode);

if ($exitCode !== 0) {
Log::error('Backup process failed with exit code: ' . $exitCode);

return response()->json([
'success' => false,
'message' => 'Backup gagal. Periksa log aplikasi untuk detail.',
], 500);
}

Log::info('Ending backup process.');

return response()->json(['success' => true, 'message' => 'Backup completed successfully']);
} catch (\Exception $e) {
Log::error('Backup process failed: ' . $e->getMessage(), ['exception' => $e]);

return response()->json(['success' => false, 'message' => 'Backup process failed', 'error' => $e->getMessage()], 500);
}
}
Expand Down Expand Up @@ -148,55 +160,59 @@ public function restoreBackup(Request $request)
'backupFile' => 'required|file',
]);

// Validasi tipe file
$allowedExtensions = ['sql'];
$allowedExtensions = ['sql', 'zip'];
$file = $request->file('backupFile');

// Validate file extension first using the original method
$extension = $file->getClientOriginalExtension();
if (!in_array($extension, $allowedExtensions)) {
return response()->json(['message' => 'File harus berupa .sql'], 422);
}

// Use FileUploadService for secure file upload

$extension = strtolower($file->getClientOriginalExtension());
if (! in_array($extension, $allowedExtensions)) {
return response()->json(['message' => 'File harus berupa .sql atau .zip'], 422);
}

$fileUploadService = new \App\Services\FileUploadService();

// Define allowed MIME types for sql files
$allowedMimes = ['application/octet-stream', 'text/plain', 'application/sql'];

// Upload file securely to temp directory
$path = $fileUploadService->uploadSecure($file, 'backup-temp', $allowedMimes, 102400); // 100MB max


if ($extension === 'zip') {
$allowedMimes = \App\Services\FileUploadService::getAllowedMimes('archive');
$maxSize = 512000; // 500MB
} else {
$allowedMimes = ['application/octet-stream', 'text/plain', 'application/sql'];
$maxSize = 102400; // 100MB
}

$path = $fileUploadService->uploadSecure($file, 'backup-temp', $allowedMimes, $maxSize);

$filename = basename($path);

$allowedChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-';

if (!preg_match("/^[" . $allowedChars . "]+$/", $filename)) {
if (! preg_match("/^[" . $allowedChars . "]+$/", $filename)) {
return response()->json(['message' => 'Nama file tidak valid, Hanya boleh mengandung huruf (a-z/A-Z), angka (0-9), titik (.), dan garis bawah (_)'], 422);
}

// Simpan file ke direktori sementara
$file = $request->file('backupFile');
$setDir = 'backup-temp';
// The path is already handled by the FileUploadService above, so we don't need this line anymore
// The path variable is already set from the uploadSecure method


try {
Log::info('Starting restore process.');

$finalPath = Storage::path($path);
Log::info("Normalized SQL file path from upload: $finalPath");
$finalPath = Storage::disk('public')->path($path);

if ($extension === 'zip') {
Log::info('Restore from ZIP: ' . $finalPath);
$result = $this->restoreFromZip($finalPath);

return response()->json([
'message' => "Restore berhasil. Database dan {$result['files_restored']} file asset telah dipulihkan.",
], 200);
}

// Jalankan proses restore
Log::info('Normalized SQL file path from upload: ' . $finalPath);
$this->runRestoreDatabase($finalPath);

return response()->json(['message' => 'Database berhasil direstore.'], 200);
} catch (\Exception $e) {
Log::error('Restore error: ' . $e->getMessage());
return response()->json(['message' => $e->getMessage()], 500);
} finally {
// Hapus file sementara
$this->deleteTemporaryDirectory(Storage::path($setDir));
$this->deleteTemporaryDirectory(Storage::disk('public')->path($setDir));
Log::info('Direktori sementara berhasil dihapus.');
}
}
Expand All @@ -210,19 +226,24 @@ private function runRestoreDatabase($sqlFilePath)
$dbUser = config('database.connections.mysql.username');
$dbPass = config('database.connections.mysql.password');

// Gunakan path binary dari konfigurasi (sama seperti spatie untuk mysqldump)
$binaryPath = config('database.connections.mysql.dump.dump_binary_path', '');
$mysqlBinary = $binaryPath
? rtrim($binaryPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'mysql'
: 'mysql';

// Buat command untuk restore database
$command = sprintf(
'mysql -h%s -P%s -u%s %s %s < "%s"',
'%s -h%s -P%s -u%s %s %s < "%s" 2>&1',
escapeshellarg($mysqlBinary),
escapeshellarg($dbHost),
escapeshellarg($dbPort),
escapeshellarg($dbUser),
$dbPass !== '' ? '-p' . escapeshellarg($dbPass) : '',
$dbPass !== '' ? '--password=' . escapeshellarg($dbPass) : '',
escapeshellarg($dbName),
$sqlFilePath
);

// $this->info("Executing command: $command");

exec($command, $output, $returnVar);

// cek hasil
Expand All @@ -234,6 +255,138 @@ private function runRestoreDatabase($sqlFilePath)
Log::info('Database restored successfully.');
}

/**
* Restore file asset dan database dari file ZIP backup (spatie/laravel-backup).
*/
private function restoreFromZip($zipFilePath)
{
$zip = new \ZipArchive();

if ($zip->open($zipFilePath) !== true) {
throw new \Exception('Gagal membuka file ZIP. Pastikan file tidak rusak.');
}

$sqlDumpPath = null;
$filesRestored = 0;
$storageBase = storage_path('app');
$tempBase = storage_path('app/public/backup-temp');
$tempSqlPath = $tempBase . '/restore-dump.sql';

try {
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);

// Skip directories (entries ending with /)
if (str_ends_with($entry, '/')) {
continue;
}

// Normalize backslash to forward slash (cross-platform ZIP entries)
$normalized = str_replace('\\', '/', $entry);

// Database dump β€” ekstrak ke file terpisah untuk restore via mysql
if (str_starts_with($normalized, 'db-dumps/')) {
$zip->extractTo($tempBase, $entry);
$extractedPath = $tempBase . '/' . $entry;
if (file_exists($extractedPath)) {
rename($extractedPath, $tempSqlPath);
$sqlDumpPath = $tempSqlPath;
}
$dbDumpsDir = $tempBase . '/db-dumps';
if (is_dir($dbDumpsDir)) {
$this->deleteTemporaryDirectory($dbDumpsDir);
}
continue;
}

// File storage β€” map ke path relatif di dalam storage/app/
$relativePath = $this->mapZipEntryToStoragePath($normalized);
if ($relativePath === null) {
continue;
}

// Build target path absolut
$targetPath = $storageBase . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
$targetDir = dirname($targetPath);

// Validasi: target harus berada di dalam storage/app/
$normalizedTargetDir = str_replace('\\', '/', $targetDir);
$normalizedStorageBase = str_replace('\\', '/', $storageBase);
if (! str_starts_with($normalizedTargetDir, $normalizedStorageBase)) {
Log::warning('Skipping entry outside storage: ' . $entry);
continue;
}

// Buat direktori jika belum ada
if (! is_dir($targetDir)) {
mkdir($targetDir, 0755, true);
}

// Ekstrak file individual
$content = $zip->getFromIndex($i);
if ($content !== false) {
file_put_contents($targetPath, $content);
$filesRestored++;
} else {
Log::warning('Failed to extract from ZIP: ' . $entry);
}
}

// Restore database dari SQL dump yang ditemukan di ZIP
if ($sqlDumpPath && file_exists($sqlDumpPath)) {
Log::info('Restoring database from ZIP dump: ' . $sqlDumpPath);
$this->runRestoreDatabase($sqlDumpPath);
} else {
Log::warning('No database dump found in ZIP β€” only file assets restored');
}

Log::info('Restore from ZIP completed: ' . $filesRestored . ' files restored');

return ['files_restored' => $filesRestored];
} finally {
$zip->close();
if (file_exists($tempSqlPath)) {
unlink($tempSqlPath);
}
}
}

/**
* Map ZIP entry ke path relatif di dalam storage/app/.
* Menangani baik path absolute (backup lama, relative_path=null) maupun
* path relative (backup baru, relative_path=base_path()).
*/
private function mapZipEntryToStoragePath($entry)
{
$skipDirs = ['backup-storage/', 'backup-temp/', 'framework/', 'logs/', 'debugbar/'];

$marker = 'storage/app/';
$pos = strpos($entry, $marker);

if ($pos === false) {
return null;
}

$relativePath = substr($entry, $pos + strlen($marker));

// Security: reject paths containing directory traversal
if (str_contains($relativePath, '..')) {
return null;
}

// Skip direktori yang dikecualikan
foreach ($skipDirs as $dir) {
if (str_starts_with($relativePath, $dir)) {
return null;
}
}

if (empty($relativePath)) {
return null;
}

return $relativePath;
}

private function deleteTemporaryDirectory($path)
{
Expand Down
4 changes: 3 additions & 1 deletion config/backup.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
base_path('storage/debugbar'),
base_path('storage/framework'),
base_path('storage/logs'),
base_path('storage/app/backup-storage'),
base_path('storage/app/backup-temp'),
],

/*
Expand All @@ -43,7 +45,7 @@
* Set to `null` to include complete absolute path
* Example: base_path()
*/
'relative_path' => null,
'relative_path' => base_path(),
],

/*
Expand Down
20 changes: 6 additions & 14 deletions resources/views/setting/pengaturan_database/table-backup.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,30 +86,22 @@ class: 'text-center',
"_token": "{{ csrf_token() }}",
},
beforeSend: function() {
restoreMessage.html('<p>Processing, please wait...</p>');
restoreMessage.html('<p class="text-info">Sedang di proses, mohon tunggu...</p>');
btnBackup.removeClass("btn-social");
btnBackup.attr("disabled", true);
btnBackup.html('<i class="fa fa-spinner fa-spin"></i> Loading...');
btnBackup.html('<i class="fa fa-spinner fa-spin"></i> Sedang di proses...');
},
success: function(response) {
$('#data-backup-database').DataTable().ajax.reload();

btnBackup.addClass("btn-social");
btnBackup.attr("disabled", false);
btnBackup.html('<i class="fa fa-plus"></i>Buat Cadangan Baru Database');
restoreMessage.html(
'<p class="text-success">Berhasil membuat salinan database.</p>'
);
},
error: function(xhr, status, error) {
restoreMessage.html('<p class="text-danger">Error: ' + xhr.responseJSON
.message + '</p>');

btnBackup.addClass("btn-social");
btnBackup.attr("disabled", false);
btnBackup.html('<i class="fa fa-plus"></i>Buat Cadangan Baru Database');
restoreMessage.html('<p class="text-danger">Error: ' + (xhr.responseJSON?.message || 'Gagal melakukan backup.') + '</p>');
},
complete: function() {
restoreMessage.html(
'<p class="text-success">Berhasil membuat salinan database.</p>'
);
btnBackup.addClass("btn-social");
btnBackup.attr("disabled", false);
btnBackup.html('<i class="fa fa-plus"></i>Buat Cadangan Baru Database');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@
@section('content_pengaturan_database')
<form id="restoreDatabaseForm" enctype="multipart/form-data">
<div class="form-group">
<label for="backupFile">File input</label>
<input type="file" id="backupFile" class="form-control" name="backupFile" accept=".sql" required>
<p class="help-block">Unggah file database (.sql)</p>
<button type="submit" class="btn btn-primary btn-sm" id="btnSubmit">
<label for="backupFile">File Backup</label>
<input type="file" id="backupFile" class="form-control" name="backupFile" accept=".sql,.zip" required>
<p class="help-block">Unggah file backup (.sql atau .zip)</p>
<div class="callout callout-warning" style="margin-top: 10px;">
<p><strong>Informasi:</strong></p>
<ul style="margin-bottom: 0;">
<li>File <strong>.sql</strong> β€” hanya memulihkan database</li>
<li>File <strong>.zip</strong> β€” memulihkan <strong>database + file asset</strong> (foto, dokumen, dll) dari backup spatie</li>
</ul>
<p style="margin-top: 8px; margin-bottom: 0;"><strong>Peringatan:</strong> Restore file .zip akan menimpa file yang ada di storage. Pastikan data penting sudah dicadangkan.</p>
</div>
<button type="submit" class="btn btn-primary btn-sm" id="btnSubmit" style="margin-top: 10px;">
<i class="fa fa-refresh"></i> Restore
</button>
</div>
Expand Down Expand Up @@ -34,7 +42,7 @@
processData: false,
contentType: false,
success: function(response) {
restoreMessage.html('<p class="text-success">Database restored successfully!</p>');
restoreMessage.html('<p class="text-success">' + response.message + '</p>');
buttonSubmit.attr("disabled", false)
$('#restoreDatabaseForm')[0].reset();
},
Expand Down
Loading
Loading