settings = $settings; $this->daUser = $daUser; $this->pluginRoot = defined('PLUGIN_ROOT') ? PLUGIN_ROOT : dirname(__DIR__, 2); $this->dataDir = $this->pluginRoot . '/data'; $this->jobsPendingDir = $this->dataDir . '/jobs/pending'; $this->jobsProcessingDir = $this->dataDir . '/jobs/processing'; $this->jobsDoneDir = $this->dataDir . '/jobs/done'; $this->logsDir = $this->dataDir . '/logs'; $this->lockDir = $this->dataDir . '/lock'; $this->uploadsDir = $this->dataDir . '/uploads'; } public function ensureInfrastructure(): void { $dirs = [ $this->dataDir, $this->dataDir . '/jobs', $this->jobsPendingDir, $this->jobsProcessingDir, $this->jobsDoneDir, $this->logsDir, $this->lockDir, $this->dataDir . '/pids', $this->dataDir . '/cancel', $this->uploadsDir, $this->uploadsDir . '/' . $this->daUser->username(), ]; foreach ($dirs as $dir) { if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) { throw new RuntimeException('Nie można utworzyć katalogu: ' . $dir); } @chmod($dir, 0700); } } public function backupEnabled(): bool { return $this->settings->enableBackup(); } public function uploadBackupEnabled(): bool { return $this->settings->enableUploadBackup(); } public function userBackupRoot(): string { $root = $this->settings->userBaseBackupDir($this->daUser->username()); if ($root === '') { $root = '/home/' . $this->daUser->username() . '/mysql_backup'; } return rtrim($root, '/'); } public function scriptBackupRoot(): string { $root = $this->settings->hitmeBackupLocation(); if ($root === '') { $root = '/home/admin/mysql_backup'; } return rtrim($root, '/'); } public function userBackupDateDir(): string { $format = $this->settings->userBackupDateFormat(); $dir = date($format); if ($dir === '' || strpos($dir, '..') !== false || strpos($dir, '/') !== false || strpos($dir, '\\') !== false) { return date('d.m.Y-H.i'); } if (!preg_match('/[Hhis]/', $format)) { $dir .= '-' . date('H.i.s'); } $base = $dir; $root = $this->userBackupRoot(); $attempt = 0; while (is_dir($root . '/' . $dir)) { $attempt++; $dir = $base . '-' . date('His') . ($attempt > 1 ? '-' . $attempt : ''); } return $dir; } public function queueBackupJob(string $dbName): string { if (!$this->backupEnabled()) { throw new RuntimeException('Tworzenie backupu jest wyłączone w konfiguracji pluginu.'); } $this->ensureInfrastructure(); $this->assertDatabaseBelongsToUser($dbName); $jobId = $this->buildJobId('backup'); $lockPath = $this->dbLockPath($dbName); $compress = $this->settings->compressBackup() ? '1' : '0'; $backupRoot = $this->userBackupRoot(); $dateDir = $this->userBackupDateDir(); return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $compress, $backupRoot, $dateDir): string { $this->acquireDbLock($dbName, 'backup', $jobId, $lockPath); try { $jobFile = $this->jobsPendingDir . '/backup-' . $jobId . '.env'; $payload = [ 'JOB_ID' => $jobId, 'JOB_TYPE' => 'backup', 'DA_USER' => $this->daUser->username(), 'DB_NAME' => $dbName, 'START_TS' => (string)time(), 'DATE_DIR' => $dateDir, 'COMPRESS_BACKUP' => $compress, 'USER_BASE_BACKUP_DIR' => $backupRoot, 'DB_LOCK_FILE' => $lockPath, ]; $this->writeJobFile($jobFile, $payload); } catch (Throwable $e) { $this->releaseDbLock($lockPath); throw $e; } $this->triggerWorker(); return $jobId; }); } public function queueRestoreFromLocal(string $relativeBackupPath, string $restoreMode = 'overwrite', string $targetDb = ''): string { if (!$this->uploadBackupEnabled()) { throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); } $this->ensureInfrastructure(); $resolved = $this->resolveLocalBackupEntry($relativeBackupPath); $sourceDb = $resolved['db_name']; if ($sourceDb === '') { throw new RuntimeException('Nie można ustalić docelowej bazy dla backupu lokalnego.'); } $restoreMode = strtolower(trim($restoreMode)); if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { $restoreMode = 'overwrite'; } $dbName = $sourceDb; if ($restoreMode === 'new_db') { if ($targetDb === '') { throw new RuntimeException('Wskaż docelową bazę danych.'); } $dbName = $targetDb; } $this->assertDatabaseBelongsToUser($dbName); $jobId = $this->buildJobId('restore'); $lockPath = $this->dbLockPath($dbName); return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $resolved, $restoreMode, $sourceDb): string { $this->acquireDbLock($dbName, 'restore', $jobId, $lockPath); try { $jobFile = $this->jobsPendingDir . '/restore-' . $jobId . '.env'; $payload = [ 'JOB_ID' => $jobId, 'JOB_TYPE' => 'restore', 'DA_USER' => $this->daUser->username(), 'DB_NAME' => $dbName, 'SOURCE_DB_NAME' => $sourceDb, 'RESTORE_MODE' => $restoreMode, 'SOURCE_FILE' => $resolved['path'], 'SOURCE_KIND' => 'local', 'START_TS' => (string)time(), 'DATE_DIR' => $resolved['date'], 'DB_LOCK_FILE' => $lockPath, ]; $this->writeJobFile($jobFile, $payload); } catch (Throwable $e) { $this->releaseDbLock($lockPath); throw $e; } $this->triggerWorker(); return $jobId; }); } public function queueRestoreFromFilePath(string $dbName, string $filePath, string $restoreMode = 'overwrite', string $sourceDbName = ''): string { if (!$this->uploadBackupEnabled()) { throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); } $this->ensureInfrastructure(); $this->assertDatabaseBelongsToUser($dbName); $filePath = trim($filePath); if ($filePath === '') { throw new RuntimeException('Wybierz plik .sql/.gz/.sql.gz lub wskaż ścieżkę pliku na serwerze.'); } if (!is_file($filePath) || !is_readable($filePath)) { throw new RuntimeException('Nie można odczytać pliku: ' . $filePath); } $realPath = @realpath($filePath); if ($realPath === false) { throw new RuntimeException('Nie można odczytać pliku: ' . $filePath); } $allowedRoots = [ '/home/' . $this->daUser->username(), $this->uploadsDir . '/' . $this->daUser->username(), ]; $inAllowedRoot = false; foreach ($allowedRoots as $rootPath) { $realRoot = @realpath($rootPath); if ($realRoot === false) { continue; } if ($realPath === $realRoot || strpos($realPath, rtrim($realRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) === 0) { $inAllowedRoot = true; break; } } if (!$inAllowedRoot) { throw new RuntimeException('Ścieżka pliku musi znajdować się w katalogu /home/' . $this->daUser->username() . ' lub pochodzić z uploadu formularza.'); } if (!$this->isAllowedSqlFile($filePath)) { throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); } $jobId = $this->buildJobId('restore'); $safeBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($filePath)) ?? 'restore.sql'; $targetDir = $this->uploadsDir . '/' . $this->daUser->username(); if (!is_dir($targetDir) && !@mkdir($targetDir, 0700, true) && !is_dir($targetDir)) { throw new RuntimeException('Nie można utworzyć katalogu upload: ' . $targetDir); } @chmod($targetDir, 0700); $copiedFile = $targetDir . '/' . $jobId . '_' . $safeBase; if (!@copy($filePath, $copiedFile)) { throw new RuntimeException('Nie udało się skopiować pliku do kolejki restore.'); } @chmod($copiedFile, 0600); $restoreMode = strtolower(trim($restoreMode)); if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { $restoreMode = 'overwrite'; } // Bezpieczeństwo: dla restore z pliku ignorujemy źródłową nazwę bazy. // Import ma być wykonywany wyłącznie do DB wskazanej przez użytkownika. $sourceDbName = ''; $lockPath = $this->dbLockPath($dbName); try { return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $copiedFile, $restoreMode, $sourceDbName): string { $this->acquireDbLock($dbName, 'restore', $jobId, $lockPath); try { $jobFile = $this->jobsPendingDir . '/restore-' . $jobId . '.env'; $payload = [ 'JOB_ID' => $jobId, 'JOB_TYPE' => 'restore', 'DA_USER' => $this->daUser->username(), 'DB_NAME' => $dbName, 'RESTORE_MODE' => $restoreMode, 'SOURCE_DB_NAME' => $sourceDbName, 'SOURCE_FILE' => $copiedFile, 'SOURCE_KIND' => 'file', 'START_TS' => (string)time(), 'DATE_DIR' => date('Y-m-d'), 'DB_LOCK_FILE' => $lockPath, ]; $this->writeJobFile($jobFile, $payload); } catch (Throwable $e) { $this->releaseDbLock($lockPath); throw $e; } $this->triggerWorker(); return $jobId; }); } catch (Throwable $e) { @unlink($copiedFile); throw $e; } } public function stageRestoreUploadFromStream(string $originalName): string { if (!$this->uploadBackupEnabled()) { throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); } $this->ensureInfrastructure(); $originalName = basename(trim($originalName)); if ($originalName === '') { throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); } if (!$this->isAllowedSqlFile($originalName)) { throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); } $targetFile = $this->buildStagedUploadPath($originalName, 'stream'); $in = false; $candidates = (PHP_SAPI === 'cli') ? ['php://stdin', 'php://input'] : ['php://input', 'php://stdin']; foreach ($candidates as $candidate) { $stream = @fopen($candidate, 'rb'); if (is_resource($stream)) { $in = $stream; break; } } if (!is_resource($in)) { throw new RuntimeException('Nie można odczytać przesyłanego pliku.'); } $out = @fopen($targetFile, 'wb'); if (!is_resource($out)) { @fclose($in); throw new RuntimeException('Nie można zapisać przesyłanego pliku na serwerze.'); } $written = @stream_copy_to_stream($in, $out); @fclose($out); @fclose($in); if ($written === false || (int)$written <= 0) { $len = (string)($_SERVER['CONTENT_LENGTH'] ?? (getenv('CONTENT_LENGTH') ?: '')); @error_log(sprintf( "[%s] UPLOAD_BLOB_STREAM_EMPTY user=%s name=%s content_length=%s target=%s\n", date('c'), $this->daUser->username(), $originalName, $len, $targetFile ), 3, PLUGIN_ROOT . '/error.log'); @unlink($targetFile); throw new RuntimeException('Przesłany plik jest pusty lub uszkodzony.'); } @chmod($targetFile, 0600); return $targetFile; } /** * @param array $upload */ public function stageRestoreUploadFromPhpUpload(array $upload, string $nameHint = ''): string { if (!$this->uploadBackupEnabled()) { throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); } $this->ensureInfrastructure(); $error = isset($upload['error']) ? (int)$upload['error'] : UPLOAD_ERR_NO_FILE; if ($error !== UPLOAD_ERR_OK) { if ($error === UPLOAD_ERR_NO_FILE) { throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); } throw new RuntimeException('Nie udało się przesłać pliku backupu (kod błędu: ' . $error . ').'); } $tmpName = isset($upload['tmp_name']) ? trim((string)$upload['tmp_name']) : ''; if ($tmpName === '' || !is_file($tmpName) || !is_readable($tmpName)) { throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.'); } $originalName = isset($upload['name']) ? basename((string)$upload['name']) : ''; if ($originalName === '') { $originalName = basename(trim($nameHint)); } if ($originalName === '') { $originalName = basename($tmpName); } if ($originalName === '' || !$this->isAllowedSqlFile($originalName)) { throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); } $targetFile = $this->buildStagedUploadPath($originalName, 'php'); $moved = @move_uploaded_file($tmpName, $targetFile); if (!$moved) { if (!@copy($tmpName, $targetFile)) { throw new RuntimeException('Nie udało się zapisać przesłanego pliku do kolejki restore.'); } @unlink($tmpName); } @chmod($targetFile, 0600); clearstatcache(true, $targetFile); $size = @filesize($targetFile); if (!is_int($size) || $size <= 0) { @unlink($targetFile); throw new RuntimeException('Przesłany plik jest pusty lub uszkodzony.'); } return $targetFile; } public function stageRestoreUploadFromDirectAdminTemp(string $uploadReference, string $originalName = ''): string { if (!$this->uploadBackupEnabled()) { throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); } $this->ensureInfrastructure(); $uploadReference = trim($uploadReference); if ($uploadReference === '') { throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); } $token = basename(str_replace('\\', '/', $uploadReference)); if ($token === '') { throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.'); } $sourceCandidates = []; if (str_starts_with($uploadReference, '/') && is_file($uploadReference)) { $sourceCandidates[] = $uploadReference; } $tmpDirs = [ (string)(getenv('UPLOAD_TMP_DIR') ?: ''), (string)(getenv('DA_UPLOAD_TMP_DIR') ?: ''), '/home/tmp', '/tmp', '/var/tmp', sys_get_temp_dir(), ]; foreach ($tmpDirs as $dir) { $dir = trim((string)$dir); if ($dir === '') { continue; } $sourceCandidates[] = rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $token; } $sourceCandidates = array_values(array_unique($sourceCandidates)); $sourcePath = ''; foreach ($sourceCandidates as $candidate) { if (is_file($candidate) && is_readable($candidate)) { $sourcePath = $candidate; break; } } if ($sourcePath === '') { @error_log(sprintf( "[%s] UPLOAD_DA_TMP_MISSING user=%s ref=%s token=%s candidates=%s\n", date('c'), $this->daUser->username(), $uploadReference, $token, implode('|', $sourceCandidates) ), 3, PLUGIN_ROOT . '/error.log'); throw new RuntimeException('Nie można odczytać przesłanego pliku backupu. Wybierz plik ponownie.'); } $originalName = basename(trim($originalName)); if ($originalName === '' || !$this->isAllowedSqlFile($originalName)) { $originalName = $this->isAllowedSqlFile($token) ? $token : 'restore.sql'; } if (!$this->isAllowedSqlFile($originalName)) { throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); } $targetFile = $this->buildStagedUploadPath($originalName, 'da'); if (!@copy($sourcePath, $targetFile)) { throw new RuntimeException('Nie udało się zapisać przesłanego pliku do kolejki restore.'); } @chmod($targetFile, 0600); clearstatcache(true, $targetFile); $size = @filesize($targetFile); if (!is_int($size) || $size <= 0) { @unlink($targetFile); throw new RuntimeException('Przesłany plik jest pusty lub uszkodzony.'); } @error_log(sprintf( "[%s] UPLOAD_DA_TMP_STAGED user=%s ref=%s src=%s dst=%s size=%s\n", date('c'), $this->daUser->username(), $uploadReference, $sourcePath, $targetFile, (string)$size ), 3, PLUGIN_ROOT . '/error.log'); return $targetFile; } /** * @param array $upload */ public function queueRestoreFromUploadedFile(string $dbName, array $upload, string $restoreMode = 'overwrite'): string { if (!$this->uploadBackupEnabled()) { throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); } $this->ensureInfrastructure(); $this->assertDatabaseBelongsToUser($dbName); $error = isset($upload['error']) ? (int)$upload['error'] : UPLOAD_ERR_NO_FILE; if ($error !== UPLOAD_ERR_OK) { if ($error === UPLOAD_ERR_NO_FILE) { throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); } throw new RuntimeException('Nie udało się przesłać pliku backupu (kod błędu: ' . $error . ').'); } $tmpName = isset($upload['tmp_name']) ? trim((string)$upload['tmp_name']) : ''; if ($tmpName === '' || !is_file($tmpName) || !is_readable($tmpName)) { throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.'); } $originalName = isset($upload['name']) ? basename((string)$upload['name']) : ''; if ($originalName === '') { $originalName = 'restore.sql'; } if (!$this->isAllowedSqlFile($originalName)) { throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); } $jobId = $this->buildJobId('restore'); $restoreMode = strtolower(trim($restoreMode)); if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { $restoreMode = 'overwrite'; } $safeBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', $originalName) ?? 'restore.sql'; $targetDir = $this->uploadsDir . '/' . $this->daUser->username(); if (!is_dir($targetDir) && !@mkdir($targetDir, 0700, true) && !is_dir($targetDir)) { throw new RuntimeException('Nie można utworzyć katalogu upload: ' . $targetDir); } @chmod($targetDir, 0700); $copiedFile = $targetDir . '/' . $jobId . '_' . $safeBase; $moved = @move_uploaded_file($tmpName, $copiedFile); if (!$moved) { if (!@copy($tmpName, $copiedFile)) { throw new RuntimeException('Nie udało się zapisać przesłanego pliku do kolejki restore.'); } @unlink($tmpName); } @chmod($copiedFile, 0600); $lockPath = $this->dbLockPath($dbName); try { return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $copiedFile, $restoreMode): string { $this->acquireDbLock($dbName, 'restore', $jobId, $lockPath); try { $jobFile = $this->jobsPendingDir . '/restore-' . $jobId . '.env'; $payload = [ 'JOB_ID' => $jobId, 'JOB_TYPE' => 'restore', 'DA_USER' => $this->daUser->username(), 'DB_NAME' => $dbName, 'RESTORE_MODE' => $restoreMode, 'SOURCE_FILE' => $copiedFile, 'SOURCE_KIND' => 'file', 'START_TS' => (string)time(), 'DATE_DIR' => date('Y-m-d'), 'DB_LOCK_FILE' => $lockPath, ]; $this->writeJobFile($jobFile, $payload); } catch (Throwable $e) { $this->releaseDbLock($lockPath); throw $e; } $this->triggerWorker(); return $jobId; }); } catch (Throwable $e) { @unlink($copiedFile); throw $e; } } /** * @return array> */ public function listLocalBackupsByDate(): array { $root = $this->scriptBackupRoot(); if (!is_dir($root)) { return []; } $out = []; $dateDirs = []; $items = @scandir($root); if ($items === false) { return []; } foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $full = $root . '/' . $item; if (is_dir($full)) { $dateDirs[] = $full; } } if (empty($dateDirs)) { $dateDirs[] = $root; } foreach ($dateDirs as $dirPath) { $dirName = basename($dirPath); $files = @scandir($dirPath); if ($files === false) { continue; } foreach ($files as $file) { if ($file === '.' || $file === '..') { continue; } $fullPath = $dirPath . '/' . $file; if (!is_file($fullPath) || !$this->isAllowedSqlFile($fullPath)) { continue; } $mtime = (int)@filemtime($fullPath); $rawDateKey = $dirPath === $root ? date('Y-m-d', $mtime) : $dirName; $dateKey = $this->normalizeDateKey($rawDateKey, $mtime); $relative = ltrim(substr($fullPath, strlen(rtrim($root, '/'))), '/'); $sourceDb = $this->extractSourceDbFromBackupFile($file); if ($sourceDb !== '' && strpos($sourceDb, $this->daUser->prefix()) !== 0) { continue; } $entry = [ 'id' => $relative, 'file' => $file, 'path' => $fullPath, 'date' => $dateKey, 'time' => $mtime > 0 ? date('H:i:s', $mtime) : '', 'size' => $this->humanFileSize((int)@filesize($fullPath)), 'source_db' => $sourceDb, 'mtime' => $mtime, ]; if (!isset($out[$dateKey])) { $out[$dateKey] = []; } $out[$dateKey][] = $entry; } } foreach ($out as $date => $entries) { usort($entries, static fn(array $a, array $b): int => ($b['mtime'] <=> $a['mtime'])); $out[$date] = $entries; } uksort($out, static fn(string $a, string $b): int => strcmp($b, $a)); return $out; } /** * @return array> */ public function listUserBackupsByDate(int $limit = 500): array { $this->ensureInfrastructure(); $root = $this->userBackupRoot(); $out = []; if (!is_dir($root)) { return []; } $rootReal = @realpath($root); $jobs = $this->listJobsForCurrentUser($limit); $jobsByTarget = []; foreach ($jobs as $job) { if (($job['type'] ?? '') !== 'backup') { continue; } $jobId = (string)($job['job_id'] ?? ''); if ($jobId === '') { continue; } $targetFile = $this->resolveBackupTargetPathFromJob($job, $jobId); if ($targetFile === '') { continue; } $realTarget = @realpath($targetFile); if ($realTarget === false) { continue; } if ($rootReal !== false) { if (strpos($realTarget, rtrim($rootReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) !== 0 && $realTarget !== $rootReal) { continue; } } $jobsByTarget[$realTarget] = $job; } $dateDirs = []; $items = @scandir($root); if (is_array($items)) { foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $full = $root . '/' . $item; if (is_dir($full)) { $dateDirs[] = $full; } } } if (empty($dateDirs)) { $dateDirs[] = $root; } $dirFormat = $this->settings->userBackupDateFormat(); foreach ($dateDirs as $dirPath) { $dirName = basename($dirPath); $dirTs = 0; if ($dirPath !== $root && $dirName !== '') { $dt = @DateTime::createFromFormat($dirFormat, $dirName); if ($dt instanceof DateTime && $dt->format($dirFormat) === $dirName) { $dirTs = $dt->getTimestamp(); } } $files = @scandir($dirPath); if ($files === false) { continue; } foreach ($files as $file) { if ($file === '.' || $file === '..') { continue; } if (preg_match('/-current\\.sql(\\.gz)?$/i', $file)) { continue; } $fullPath = $dirPath . '/' . $file; if (!is_file($fullPath) || !$this->isAllowedSqlFile($fullPath)) { continue; } $realPath = @realpath($fullPath); if ($realPath === false) { continue; } if ($rootReal !== false) { if (strpos($realPath, rtrim($rootReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) !== 0 && $realPath !== $rootReal) { continue; } } $mtime = (int)@filemtime($realPath); $ts = $dirTs > 0 ? $dirTs : $mtime; if ($ts <= 0) { $ts = (int)time(); } $dateKey = date('Y-m-d', $ts); $timeLabel = date('H:i:s', $ts); $sourceDb = $this->extractSourceDbFromBackupFile($file); if ($sourceDb !== '' && strpos($sourceDb, $this->daUser->prefix()) !== 0) { continue; } $job = $jobsByTarget[$realPath] ?? null; $jobId = is_array($job) ? (string)($job['job_id'] ?? '') : ''; $status = is_array($job) ? (string)($job['status'] ?? '') : 'done_ok'; $logExists = is_array($job) ? (bool)($job['log_exists'] ?? false) : false; $dbName = is_array($job) && (string)($job['db_name'] ?? '') !== '' ? (string)$job['db_name'] : $sourceDb; $relative = ltrim(substr($realPath, strlen(rtrim($root, '/'))), '/'); $entry = [ 'job_id' => $jobId, 'db_name' => $dbName, 'date' => $dateKey, 'time' => $timeLabel, 'mtime' => $ts, 'size' => $this->humanFileSize((int)@filesize($realPath)), 'status' => $status, 'log_exists' => $logExists, 'target_file' => $realPath, 'has_file' => true, 'path' => $realPath, 'rel_path' => $relative, ]; if (!isset($out[$dateKey])) { $out[$dateKey] = []; } $out[$dateKey][] = $entry; } } foreach ($out as $date => $entries) { usort($entries, static fn(array $a, array $b): int => ((int)($b['mtime'] ?? 0) <=> (int)($a['mtime'] ?? 0))); $out[$date] = $entries; } uksort($out, static fn(string $a, string $b): int => strcmp($b, $a)); return $out; } public function resolveUserBackupFileForCurrentUser(string $relativePath): string { $relativePath = ltrim(trim($relativePath), '/'); if ($relativePath === '' || strpos($relativePath, '..') !== false) { throw new RuntimeException('Nieprawidłowa ścieżka pliku backupu.'); } $root = $this->userBackupRoot(); $candidate = $root . '/' . $relativePath; $realRoot = @realpath($root); $realPath = @realpath($candidate); if ($realRoot === false || $realPath === false) { throw new RuntimeException('Nie można zlokalizować pliku backupu.'); } if (!str_starts_with($realPath, rtrim($realRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)) { throw new RuntimeException('Plik backupu znajduje się poza dozwolonym katalogiem.'); } if (!is_file($realPath) || !$this->isAllowedSqlFile($realPath)) { throw new RuntimeException('Nie można odczytać wskazanego pliku backupu.'); } return $realPath; } public function deleteUserBackupFileForCurrentUser(string $relativePath): string { $path = $this->resolveUserBackupFileForCurrentUser($relativePath); $currentCandidate = ''; if (preg_match('/^(.*)\\.sql(\\.gz)?$/i', $path, $m)) { $currentCandidate = $m[1] . '-current.sql' . ($m[2] ?? ''); } if (!@unlink($path)) { throw new RuntimeException('Nie udało się usunąć pliku backupu: ' . $path); } if ($currentCandidate !== '' && is_file($currentCandidate)) { @unlink($currentCandidate); } $parentDir = dirname($path); if (is_dir($parentDir)) { $items = @scandir($parentDir) ?: []; $onlyDots = true; foreach ($items as $item) { if ($item !== '.' && $item !== '..') { $onlyDots = false; break; } } if ($onlyDots) { @rmdir($parentDir); } } return $path; } private function normalizeDateKey(string $raw, int $fallbackTs = 0): string { $raw = trim($raw); if ($raw === '') { return $fallbackTs > 0 ? date('Y-m-d', $fallbackTs) : ''; } $formats = ['Y-m-d', 'd-m-Y', 'Y.m.d', 'd.m.Y']; foreach ($formats as $fmt) { $dt = @DateTime::createFromFormat($fmt, $raw); if ($dt instanceof DateTime && $dt->format($fmt) === $raw) { return $dt->format('Y-m-d'); } } if ($fallbackTs > 0) { return date('Y-m-d', $fallbackTs); } return $raw; } /** * @return array */ public function listJobsForCurrentUser(int $limit = 150): array { $this->ensureInfrastructure(); $candidates = []; $this->collectJobFiles($this->jobsPendingDir, 'pending', $candidates); $this->collectJobFiles($this->jobsProcessingDir, 'processing', $candidates); $this->collectDoneJobFiles($this->jobsDoneDir, $candidates); $out = []; foreach ($candidates as $candidate) { $meta = $this->parseJobFile($candidate['path']); if (($meta['DA_USER'] ?? '') !== $this->daUser->username()) { $fallback = $this->inferJobMetaFromLog($candidate['job_id']); if ($fallback === null) { continue; } $meta = array_merge($fallback, $meta); } $jobId = (string)($meta['JOB_ID'] ?? $candidate['job_id']); if ($jobId === '') { continue; } $out[] = [ 'job_id' => $jobId, 'type' => (string)($meta['JOB_TYPE'] ?? 'unknown'), 'db_name' => (string)($meta['DB_NAME'] ?? ''), 'status' => $candidate['status'], 'start_ts' => (int)($meta['START_TS'] ?? @filemtime($candidate['path']) ?: 0), 'updated_ts' => (int)@filemtime($candidate['path']), 'file' => $candidate['path'], 'log_exists' => is_file($this->logsDir . '/' . $jobId . '.log'), 'source_file' => (string)($meta['SOURCE_FILE'] ?? ''), 'target_file' => (string)($meta['TARGET_FILE'] ?? ''), 'date_dir' => (string)($meta['DATE_DIR'] ?? ''), 'restore_mode' => (string)($meta['RESTORE_MODE'] ?? ''), 'source_db_name' => (string)($meta['SOURCE_DB_NAME'] ?? ''), ]; } usort( $out, static function (array $a, array $b): int { if ($a['start_ts'] === $b['start_ts']) { return strcmp($b['job_id'], $a['job_id']); } return $b['start_ts'] <=> $a['start_ts']; } ); if ($limit > 0 && count($out) > $limit) { return array_slice($out, 0, $limit); } return $out; } /** * @return array{ * job_id:string, * type:string, * db_name:string, * status:string, * start_ts:int, * updated_ts:int, * file:string, * log_exists:bool, * source_file:string, * target_file:string, * date_dir:string * } */ public function getJobForCurrentUser(string $jobId): array { $jobId = trim($jobId); if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { throw new RuntimeException('Nieprawidłowy identyfikator zadania.'); } $jobs = $this->listJobsForCurrentUser(500); foreach ($jobs as $job) { if ($job['job_id'] === $jobId) { return $job; } } throw new RuntimeException('Nie znaleziono zadania lub brak dostępu.'); } public function resolveBackupTargetFileForCurrentUser(string $jobId): string { $job = $this->getJobForCurrentUser($jobId); if ($job['type'] !== 'backup') { throw new RuntimeException('Wskazane zadanie nie jest zadaniem backupu.'); } if ($job['status'] !== 'done_ok') { throw new RuntimeException('Backup nie został zakończony powodzeniem.'); } $path = $this->resolveBackupTargetPathFromJob($job, $jobId); if ($path === '') { throw new RuntimeException('Brak ścieżki pliku backupu w metadanych zadania.'); } if (!is_file($path) || !is_readable($path)) { throw new RuntimeException('Plik backupu nie istnieje lub brak uprawnień odczytu (sprawdź uprawnienia katalogu i pliku backupu dla diradmin).'); } $userRoot = $this->userBackupRoot(); $globalRoot = rtrim($this->settings->hitmeBackupLocation(), '/'); if (!$this->pathInDir($path, $userRoot) && !$this->pathInDir($path, $globalRoot)) { throw new RuntimeException('Plik backupu znajduje się poza dozwolonym katalogiem.'); } return $path; } public function hasBackupTargetFileForCurrentUser(string $jobId): bool { try { $path = $this->resolveBackupTargetFileForCurrentUser($jobId); return $path !== '' && is_file($path) && is_readable($path); } catch (Throwable $e) { return false; } } public function deleteBackupTargetFileForCurrentUser(string $jobId): string { $path = $this->resolveBackupTargetFileForCurrentUser($jobId); if (!is_file($path)) { throw new RuntimeException('Plik backupu nie istnieje.'); } $currentCandidate = ''; if (preg_match('/^(.*)\\.sql(\\.gz)?$/i', $path, $m)) { $currentCandidate = $m[1] . '-current.sql' . ($m[2] ?? ''); } if (!@unlink($path)) { throw new RuntimeException('Nie udało się usunąć pliku backupu: ' . $path); } if ($currentCandidate !== '' && is_file($currentCandidate)) { @unlink($currentCandidate); } $parentDir = dirname($path); if (is_dir($parentDir)) { $items = @scandir($parentDir) ?: []; $onlyDots = true; foreach ($items as $item) { if ($item !== '.' && $item !== '..') { $onlyDots = false; break; } } if ($onlyDots) { @rmdir($parentDir); } } return $path; } public function readLogForCurrentUser(string $jobId): string { $jobId = trim($jobId); if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { throw new RuntimeException('Nieprawidłowy identyfikator zadania.'); } $jobs = $this->listJobsForCurrentUser(); $allowed = false; foreach ($jobs as $job) { if ($job['job_id'] === $jobId) { $allowed = true; break; } } if (!$allowed) { throw new RuntimeException('Brak dostępu do logu zadania.'); } $path = $this->logsDir . '/' . $jobId . '.log'; if (!is_file($path) || !is_readable($path)) { return 'Brak logu dla wybranego zadania.'; } $content = @file_get_contents($path); if ($content === false || trim($content) === '') { return 'Log jest pusty.'; } return $content; } public function deleteBackupLogForCurrentUser(string $jobId): string { $jobId = trim($jobId); if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { throw new RuntimeException('Nieprawidłowy identyfikator zadania.'); } $jobs = $this->listJobsForCurrentUser(); $allowed = false; foreach ($jobs as $job) { if ($job['job_id'] === $jobId) { $allowed = true; break; } } if (!$allowed) { throw new RuntimeException('Brak dostępu do logu zadania.'); } $path = $this->logsDir . '/' . $jobId . '.log'; if (!is_file($path)) { throw new RuntimeException('Log nie istnieje.'); } if (!@unlink($path)) { throw new RuntimeException('Nie udało się usunąć logu zadania.'); } return $path; } public function isDbLocked(string $dbName): bool { return is_file($this->dbLockPath($dbName)); } /** * @return array{path:string,date:string,db_name:string} */ public function getLocalBackupEntry(string $relativePath): array { return $this->resolveLocalBackupEntry($relativePath); } public function dbLockPath(string $dbName): string { $safe = preg_replace('/[^A-Za-z0-9_.-]+/', '_', $dbName) ?? 'db'; return $this->lockDir . '/' . $safe . '.lock'; } private function assertDatabaseBelongsToUser(string $dbName): void { if (strpos($dbName, $this->daUser->prefix()) !== 0) { throw new RuntimeException('Baza danych nie należy do konta DirectAdmin: ' . $dbName); } } /** * @return array{path:string,date:string,db_name:string} */ private function resolveLocalBackupEntry(string $relativePath): array { $relativePath = trim(str_replace('\\', '/', $relativePath)); if ($relativePath === '') { throw new RuntimeException('Nie wybrano pliku backupu z lokalnego katalogu.'); } if (strpos($relativePath, '..') !== false || str_starts_with($relativePath, '/')) { throw new RuntimeException('Nieprawidłowa ścieżka backupu.'); } $root = $this->scriptBackupRoot(); $fullPath = $root . '/' . ltrim($relativePath, '/'); if (!is_file($fullPath) || !is_readable($fullPath)) { throw new RuntimeException('Nie można odczytać wskazanego pliku backupu.'); } if (!$this->isAllowedSqlFile($fullPath)) { throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); } $realRoot = @realpath($root); $realPath = @realpath($fullPath); if ($realRoot === false || $realPath === false || !str_starts_with($realPath, rtrim($realRoot, '/') . '/')) { throw new RuntimeException('Plik backupu znajduje się poza dozwolonym katalogiem.'); } $dateDir = basename(dirname($realPath)); if ($dateDir === '' || $dateDir === '.' || $dateDir === '..') { $dateDir = date('Y-m-d'); } $dbName = $this->extractSourceDbFromBackupFile(basename($realPath)); if ($dbName === '') { throw new RuntimeException('Nie można ustalić docelowej bazy dla backupu lokalnego.'); } return ['path' => $realPath, 'date' => $dateDir, 'db_name' => $dbName]; } public function detectSourceDatabaseFromBackupName(string $pathOrFile): string { $candidate = $this->extractSourceDbFromBackupFile(basename($pathOrFile)); if ($candidate === '') { return ''; } if (strpos($candidate, $this->daUser->prefix()) !== 0) { return ''; } return $candidate; } public function isAllowedSqlFile(string $path): bool { return (bool)preg_match('/\.(?:sql\.gz|sql|gz)$/i', basename($path)); } private function extractSourceDbFromBackupFile(string $file): string { $name = preg_replace('/\.(?:sql\.gz|sql|gz)$/i', '', $file) ?? $file; if ($name === '') { return ''; } $parts = explode('__', $name, 2); $candidate = strtolower(trim($parts[0])); if ($candidate === '' || !preg_match('/^[a-z0-9_]+$/', $candidate)) { return ''; } return $candidate; } private function buildStagedUploadPath(string $originalName, string $prefix): string { $safeBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($originalName)) ?? 'restore.sql'; $targetDir = $this->uploadsDir . '/' . $this->daUser->username(); if (!is_dir($targetDir) && !@mkdir($targetDir, 0700, true) && !is_dir($targetDir)) { throw new RuntimeException('Nie można utworzyć katalogu upload: ' . $targetDir); } @chmod($targetDir, 0700); $token = date('YmdHis') . '-' . bin2hex(random_bytes(4)); return $targetDir . '/' . $prefix . '-' . $token . '_' . $safeBase; } private function buildJobId(string $type): string { return $type . '-' . date('YmdHis') . '-' . bin2hex(random_bytes(4)); } private function withQueueLock(callable $callback) { $lockDir = $this->dataDir . '/jobs/queue.lock'; $tries = 0; while (!@mkdir($lockDir, 0700)) { $tries++; if (!is_dir($lockDir)) { continue; } $mtime = (int)@filemtime($lockDir); if ($mtime > 0 && (time() - $mtime) > 120) { @rmdir($lockDir); } if ($tries >= 40) { throw new RuntimeException('Nie można uzyskać blokady kolejki zadań. Spróbuj ponownie.'); } usleep(100000); } try { return $callback(); } finally { @rmdir($lockDir); } } private function acquireDbLock(string $dbName, string $jobType, string $jobId, string $lockPath): void { $handle = @fopen($lockPath, 'x'); if ($handle === false) { $existing = is_file($lockPath) ? trim((string)@file_get_contents($lockPath)) : ''; $msg = 'Na bazie danych trwa już operacja. Zaczekaj na zakończenie poprzedniego zadania.'; if ($existing !== '') { $msg .= ' [' . $existing . ']'; } throw new RuntimeException($msg); } $content = implode( "\n", [ 'DB_NAME=' . $dbName, 'JOB_TYPE=' . $jobType, 'JOB_ID=' . $jobId, 'DA_USER=' . $this->daUser->username(), 'CREATED_AT=' . date('c'), ] ) . "\n"; fwrite($handle, $content); fclose($handle); @chmod($lockPath, 0600); } private function releaseDbLock(string $lockPath): void { if (is_file($lockPath)) { @unlink($lockPath); } } /** * @param array $payload */ private function writeJobFile(string $path, array $payload): void { $lines = []; foreach ($payload as $key => $value) { $lines[] = $key . '=' . $this->envQuote($value); } $content = implode("\n", $lines) . "\n"; if (@file_put_contents($path, $content, LOCK_EX) === false) { throw new RuntimeException('Nie udało się zapisać pliku zadania: ' . $path); } @chmod($path, 0600); } private function envQuote(string $value): string { return "'" . str_replace("'", "'\\''", $value) . "'"; } private function triggerWorker(): void { $script = $this->pluginRoot . '/scripts/worker.sh'; if (!is_file($script)) { return; } $euid = null; if (function_exists('posix_geteuid')) { $euid = @posix_geteuid(); } else { $raw = @shell_exec('id -u 2>/dev/null'); if (is_string($raw) && preg_match('/^[0-9]+$/', trim($raw))) { $euid = (int)trim($raw); } } // Worker powinien wykonywać zadania z crona jako root, aby mieć dostęp // do katalogów backupu i konfiguracji systemowej. if ($euid !== null && $euid !== 0) { return; } $cmd = '/bin/bash ' . escapeshellarg($script) . ' >/dev/null 2>&1 &'; @exec($cmd); } /** * @param array $target */ private function collectJobFiles(string $dir, string $status, array &$target): void { if (!is_dir($dir)) { return; } $files = glob($dir . '/*.env'); if ($files === false) { return; } foreach ($files as $path) { $base = basename($path); $jobId = substr($base, 0, -4); $target[] = ['path' => $path, 'status' => $status, 'job_id' => $jobId]; } } /** * @param array $target */ private function collectDoneJobFiles(string $dir, array &$target): void { if (!is_dir($dir)) { return; } $patterns = ['*.ok' => 'done_ok', '*.fail' => 'done_fail', '*.cancel' => 'done_cancel']; foreach ($patterns as $pattern => $status) { $files = glob($dir . '/' . $pattern); if ($files === false) { continue; } foreach ($files as $path) { $base = basename($path); $jobId = preg_replace('/\.(ok|fail|cancel)$/', '', $base) ?? $base; $target[] = ['path' => $path, 'status' => $status, 'job_id' => $jobId]; } } } /** * @return array */ private function parseJobFile(string $path): array { $out = []; if (!is_readable($path)) { return $out; } $lines = @file($path, FILE_IGNORE_NEW_LINES); if ($lines === false) { return $out; } foreach ($lines as $line) { $line = trim($line); if ($line === '' || $line[0] === '#') { continue; } if (!preg_match('/^([A-Z0-9_]+)=(.*)$/', $line, $m)) { continue; } $key = $m[1]; $value = trim((string)$m[2]); if (strlen($value) >= 2 && $value[0] === '\'' && substr($value, -1) === '\'') { $value = substr($value, 1, -1); $value = str_replace("'\\''", "'", $value); } elseif (strlen($value) >= 2 && $value[0] === '"' && substr($value, -1) === '"') { $value = stripcslashes(substr($value, 1, -1)); } $out[$key] = $value; } return $out; } private function humanFileSize(int $bytes): string { if ($bytes <= 0) { return '0 B'; } $units = ['B', 'KB', 'MB', 'GB', 'TB']; $power = (int)floor(log($bytes, 1024)); $power = min($power, count($units) - 1); $value = $bytes / (1024 ** $power); return number_format($value, $power === 0 ? 0 : 2, '.', ' ') . ' ' . $units[$power]; } private function pathInDir(string $path, string $dir): bool { $realPath = @realpath($path); $realDir = @realpath($dir); if ($realPath === false || $realDir === false) { return false; } $realDir = rtrim($realDir, '/'); if ($realPath === $realDir) { return true; } return str_starts_with($realPath, $realDir . '/'); } /** * @param array $job */ private function resolveBackupTargetPathFromJob(array $job, string $jobId): string { $path = trim((string)($job['target_file'] ?? '')); if ($path === '') { $fallback = $this->inferJobMetaFromLog($jobId); $path = $fallback !== null ? trim((string)($fallback['TARGET_FILE'] ?? '')) : ''; } return $path; } /** * @return array|null */ private function inferJobMetaFromLog(string $jobId): ?array { $jobId = trim($jobId); if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { return null; } $logPath = $this->logsDir . '/' . $jobId . '.log'; if (!is_file($logPath) || !is_readable($logPath)) { return null; } $lines = @file($logPath, FILE_IGNORE_NEW_LINES); if ($lines === false || empty($lines)) { return null; } $type = 'unknown'; $dbName = ''; $sourceFile = ''; $targetFile = ''; $startTs = 0; $restoreMode = ''; $sourceDbName = ''; foreach ($lines as $lineRaw) { $line = trim((string)$lineRaw); if ($line === '') { continue; } if ($startTs === 0 && preg_match('/^\[([0-9:-]{19})\]/', $line, $mTs)) { $parsed = strtotime($mTs[1]); if ($parsed !== false) { $startTs = (int)$parsed; } } if (stripos($line, 'Start backup job') !== false) { $type = 'backup'; } elseif (stripos($line, 'Start restore job') !== false) { $type = 'restore'; } if ($dbName === '' && preg_match('/Tworzenie backupu bazy\s+([a-z0-9_]+)/iu', $line, $mDb)) { $dbName = strtolower($mDb[1]); } if ($dbName === '' && preg_match('/Przywracanie bazy\s+([a-z0-9_]+)/iu', $line, $mDb2)) { $dbName = strtolower($mDb2[1]); } if (preg_match('/Sanityzacja dumpa:\s*([a-z0-9_]+)\s*->\s*([a-z0-9_]+)/iu', $line, $mSan)) { $sourceDbName = strtolower($mSan[1]); $restoreMode = (strtolower($mSan[1]) === strtolower($mSan[2])) ? 'overwrite' : 'new_db'; } if ($targetFile === '' && preg_match('/Backup zapisany:\s+(.+)$/u', $line, $mTarget)) { $targetFile = trim($mTarget[1]); } if ($sourceFile === '' && preg_match('/Przywracanie bazy\s+[a-z0-9_]+\s+z pliku\s+(.+)$/iu', $line, $mSource)) { $sourceFile = trim($mSource[1]); } } if ($type === 'unknown') { if (str_starts_with($jobId, 'backup-') || str_starts_with($jobId, 'backup-backup-')) { $type = 'backup'; } elseif (str_starts_with($jobId, 'restore-') || str_starts_with($jobId, 'restore-restore-')) { $type = 'restore'; } } if ($dbName === '' || strpos($dbName, $this->daUser->prefix()) !== 0) { return null; } return [ 'DA_USER' => $this->daUser->username(), 'JOB_TYPE' => $type, 'DB_NAME' => $dbName, 'SOURCE_FILE' => $sourceFile, 'TARGET_FILE' => $targetFile, 'START_TS' => $startTs > 0 ? (string)$startTs : '', 'RESTORE_MODE' => $restoreMode, 'SOURCE_DB_NAME' => $sourceDbName, ]; } }