Skip to content
Merged
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 @@ -59,4 +59,7 @@ mcp.json

.env.testing
nul
graphify-out

# Agent
/.qwen
graphify-out
295 changes: 249 additions & 46 deletions app/Http/Controllers/Setting/PengaturanDatabaseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
use Illuminate\Support\Facades\File;

use Exception;
use App\Http\Controllers\Setting\ZipArchive;


class PengaturanDatabaseController extends Controller
Expand Down Expand Up @@ -72,16 +71,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 +159,69 @@ public function restoreBackup(Request $request)
'backupFile' => 'required|file',
]);

// Validasi tipe file
$allowedExtensions = ['sql'];
$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());

// Fix #4: Hanya terima .zip dari backup system β€” .sql tidak lagi didukung
if ($extension !== 'zip') {
return response()->json([
'success' => false,
'message' => 'Hanya file .zip dari backup system yang diizinkan untuk restore.',
], 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

$allowedMimes = \App\Services\FileUploadService::getAllowedMimes('archive');
$maxSize = 512000; // 500MB

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

$filename = basename($path);

$allowedChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-';

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);
if (! preg_match("/^[" . $allowedChars . "]+$/", $filename)) {
return response()->json([
'success' => false,
'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");
// Fix #3: Pre-restore backup otomatis (best-effort, tidak memblokir restore jika gagal)
try {
Log::info('Menjalankan pre-restore backup otomatis...');
$backupExit = Artisan::call('backup:run');
if ($backupExit !== 0) {
Log::warning('Pre-restore backup gagal (exit code: ' . $backupExit . '). Restore tetap dilanjutkan.');
} else {
Log::info('Pre-restore backup berhasil.');
}
} catch (\Exception $backupEx) {
Log::warning('Pre-restore backup exception: ' . $backupEx->getMessage() . '. Restore tetap dilanjutkan.');
}

$finalPath = Storage::disk('public')->path($path);

// Jalankan proses restore
$this->runRestoreDatabase($finalPath);
Log::info('Restore from ZIP: ' . $finalPath);
$result = $this->restoreFromZip($finalPath);

return response()->json(['message' => 'Database berhasil direstore.'], 200);
return response()->json([
'success' => true,
'message' => "Restore berhasil. Database dan {$result['files_restored']} file asset telah dipulihkan.",
], 200);
} catch (\Exception $e) {
Log::error('Restore error: ' . $e->getMessage());
return response()->json(['message' => $e->getMessage()], 500);

return response()->json([
'success' => false,
'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 +235,24 @@ private function runRestoreDatabase($sqlFilePath)
$dbUser = config('database.connections.mysql.username');
$dbPass = config('database.connections.mysql.password');

// Buat command untuk restore database
// 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';

// Fix #1: Tambahkan escapeshellarg pada $sqlFilePath untuk mencegah shell injection
$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
escapeshellarg($sqlFilePath)
);

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

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

// cek hasil
Expand All @@ -234,9 +264,182 @@ 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 {
// Fix #6: Hitung jumlah db-dump entries sebelum proses β€” tolak jika > 1
$dbDumpCount = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = str_replace('\\', '/', $zip->getNameIndex($i));
if (str_starts_with($name, 'db-dumps/') && ! str_ends_with($name, '/')) {
$dbDumpCount++;
}
}
if ($dbDumpCount > 1) {
throw new \Exception("File ZIP mengandung {$dbDumpCount} database dump. Hanya 1 yang diizinkan.");
}

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/')) {
// Fix #2: Validasi path traversal dan subdirektori sebelum extractTo()
$relativeToDbDumps = substr($normalized, strlen('db-dumps/'));
if (str_contains($relativeToDbDumps, '..') || str_contains($relativeToDbDumps, '/')) {
Log::warning('Skipping db-dumps entry with traversal or subdirectory: ' . $entry);
continue;
}

$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)
{
// Fix #7: Tolak symlink dan path di luar storage/ untuk mencegah penghapusan file penting
if (is_link($path)) {
Log::warning('deleteTemporaryDirectory: path adalah symlink, dibatalkan: ' . $path);
return false;
}

$realPath = realpath($path);
$allowedBase = realpath(storage_path());

if ($realPath === false || $allowedBase === false) {
Log::warning('deleteTemporaryDirectory: path tidak dapat diresolve: ' . $path);
return false;
}

// Pastikan path berada di dalam storage/
$normalizedReal = str_replace('\\', '/', $realPath);
$normalizedBase = str_replace('\\', '/', $allowedBase);
if (! str_starts_with($normalizedReal, $normalizedBase)) {
Log::error('deleteTemporaryDirectory: path di luar storage/, dibatalkan: ' . $realPath);
return false;
}

if (is_dir($path)) {
$files = array_diff(scandir($path), ['.', '..']);
foreach ($files as $file) {
Expand Down
Loading