a570635d23
If mkdir(queue.lock) failed for a reason other than "already exists" (e.g. a permissions or disk problem on the data directory), is_dir() was false, so the loop `continue`d before ever reaching the tries>=40 retry-limit check or the usleep(), spinning at full CPU forever instead of failing with a clear error. Move the staleness check inside an is_dir() guard instead of using continue to skip past the retry-limit logic, so the bounded-retry behavior applies regardless of why mkdir failed.
1620 lines
56 KiB
PHP
1620 lines
56 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
final class BackupQueueService
|
|
{
|
|
private Settings $settings;
|
|
private DirectAdminUser $daUser;
|
|
private string $pluginRoot;
|
|
private string $dataDir;
|
|
private string $jobsPendingDir;
|
|
private string $jobsProcessingDir;
|
|
private string $jobsDoneDir;
|
|
private string $logsDir;
|
|
private string $lockDir;
|
|
private string $uploadsDir;
|
|
|
|
public function __construct(Settings $settings, DirectAdminUser $daUser)
|
|
{
|
|
$this->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<string,mixed> $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.');
|
|
}
|
|
|
|
// Security: never trust $uploadReference as a literal path - it comes
|
|
// straight from user-controlled POST data, and this whole plugin runs
|
|
// as root. Only accept a file whose *basename* matches one of the
|
|
// known DirectAdmin/system temp directories below, never an
|
|
// arbitrary absolute path chosen by the caller.
|
|
$sourceCandidates = [];
|
|
$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<string,mixed> $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<string,array<int,array{id:string,file:string,path:string,date:string,time:string,size:string,source_db:string,mtime:int}>>
|
|
*/
|
|
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<string,array<int,array{
|
|
* job_id:string,
|
|
* db_name:string,
|
|
* date:string,
|
|
* time:string,
|
|
* mtime:int,
|
|
* size:string,
|
|
* status:string,
|
|
* log_exists:bool,
|
|
* target_file:string,
|
|
* has_file:bool
|
|
* }>>
|
|
*/
|
|
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<int,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,
|
|
* restore_mode:string,
|
|
* source_db_name:string
|
|
* }>
|
|
*/
|
|
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)) {
|
|
$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<string,string> $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<int,array{path:string,status:string,job_id:string}> $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<int,array{path:string,status:string,job_id:string}> $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<string,string>
|
|
*/
|
|
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<string,mixed> $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<string,string>|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,
|
|
];
|
|
}
|
|
}
|