902 lines
35 KiB
PHP
902 lines
35 KiB
PHP
<?php
|
||
|
||
namespace App\Livewire\Project\Database;
|
||
|
||
use App\Models\S3Storage;
|
||
use App\Models\Server;
|
||
use App\Models\Service;
|
||
use App\Models\ServiceDatabase;
|
||
use App\Models\StandaloneClickhouse;
|
||
use App\Models\StandaloneDragonfly;
|
||
use App\Models\StandaloneKeydb;
|
||
use App\Models\StandaloneMariadb;
|
||
use App\Models\StandaloneMongodb;
|
||
use App\Models\StandaloneMysql;
|
||
use App\Models\StandalonePostgresql;
|
||
use App\Models\StandaloneRedis;
|
||
use App\Rules\SafeWebhookUrl;
|
||
use App\Support\DatabaseBackupFileValidator;
|
||
use App\Support\ValidationPatterns;
|
||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Livewire\Attributes\Computed;
|
||
use Livewire\Attributes\Locked;
|
||
use Livewire\Component;
|
||
|
||
class ImportForm extends Component
|
||
{
|
||
use AuthorizesRequests;
|
||
|
||
/**
|
||
* Validate that a string is safe for use as an S3 bucket name.
|
||
*/
|
||
private function validateBucketName(string $bucket): bool
|
||
{
|
||
return ValidationPatterns::isValidS3BucketName($bucket);
|
||
}
|
||
|
||
/**
|
||
* Validate that a string is safe for use as an S3 path.
|
||
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
|
||
*/
|
||
private function validateS3Path(string $path): bool
|
||
{
|
||
// Must not be empty
|
||
if (empty($path)) {
|
||
return false;
|
||
}
|
||
|
||
// Must not contain dangerous shell metacharacters or command injection patterns
|
||
$dangerousPatterns = [
|
||
'..', // Directory traversal
|
||
'$(', // Command substitution
|
||
'`', // Backtick command substitution
|
||
'|', // Pipe
|
||
';', // Command separator
|
||
'&', // Background/AND
|
||
'>', // Redirect
|
||
'<', // Redirect
|
||
"\n", // Newline
|
||
"\r", // Carriage return
|
||
"\0", // Null byte
|
||
"'", // Single quote
|
||
'"', // Double quote
|
||
'\\', // Backslash
|
||
];
|
||
|
||
foreach ($dangerousPatterns as $pattern) {
|
||
if (str_contains($path, $pattern)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
|
||
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
|
||
}
|
||
|
||
/**
|
||
* Validate that a string is safe for use as a file path on the server.
|
||
*/
|
||
private function validateServerPath(string $path): bool
|
||
{
|
||
// Must be an absolute path
|
||
if (! str_starts_with($path, '/')) {
|
||
return false;
|
||
}
|
||
|
||
// Must not contain dangerous shell metacharacters or command injection patterns
|
||
$dangerousPatterns = [
|
||
'..', // Directory traversal
|
||
'$(', // Command substitution
|
||
'`', // Backtick command substitution
|
||
'|', // Pipe
|
||
';', // Command separator
|
||
'&', // Background/AND
|
||
'>', // Redirect
|
||
'<', // Redirect
|
||
"\n", // Newline
|
||
"\r", // Carriage return
|
||
"\0", // Null byte
|
||
"'", // Single quote
|
||
'"', // Double quote
|
||
'\\', // Backslash
|
||
];
|
||
|
||
foreach ($dangerousPatterns as $pattern) {
|
||
if (str_contains($path, $pattern)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
|
||
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
|
||
}
|
||
|
||
public bool $unsupported = false;
|
||
|
||
// Store IDs instead of models for proper Livewire serialization
|
||
#[Locked]
|
||
public ?int $resourceId = null;
|
||
|
||
#[Locked]
|
||
public ?string $resourceType = null;
|
||
|
||
#[Locked]
|
||
public ?int $serverId = null;
|
||
|
||
// View-friendly properties to avoid computed property access in Blade
|
||
#[Locked]
|
||
public string $resourceUuid = '';
|
||
|
||
public string $resourceStatus = '';
|
||
|
||
#[Locked]
|
||
public string $resourceDbType = '';
|
||
|
||
public array $parameters = [];
|
||
|
||
public array $containers = [];
|
||
|
||
public bool $scpInProgress = false;
|
||
|
||
public bool $importRunning = false;
|
||
|
||
public ?string $filename = null;
|
||
|
||
public ?string $filesize = null;
|
||
|
||
public bool $isUploading = false;
|
||
|
||
public int $progress = 0;
|
||
|
||
public bool $error = false;
|
||
|
||
#[Locked]
|
||
public string $container;
|
||
|
||
public array $importCommands = [];
|
||
|
||
public bool $dumpAll = false;
|
||
|
||
public string $restoreCommandText = '';
|
||
|
||
public string $customLocation = '';
|
||
|
||
public ?int $activityId = null;
|
||
|
||
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||
|
||
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||
|
||
public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
||
|
||
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
||
|
||
// S3 Restore properties
|
||
public array $availableS3Storages = [];
|
||
|
||
public ?int $s3StorageId = null;
|
||
|
||
public string $s3Path = '';
|
||
|
||
public ?int $s3FileSize = null;
|
||
|
||
#[Computed]
|
||
public function resource()
|
||
{
|
||
if ($this->resourceId === null || $this->resourceType === null) {
|
||
return null;
|
||
}
|
||
|
||
return $this->resourceType::find($this->resourceId);
|
||
}
|
||
|
||
#[Computed]
|
||
public function server()
|
||
{
|
||
if ($this->serverId === null) {
|
||
return null;
|
||
}
|
||
|
||
return Server::ownedByCurrentTeam()->find($this->serverId);
|
||
}
|
||
|
||
protected $listeners = [
|
||
'slideOverClosed' => 'resetActivityId',
|
||
];
|
||
|
||
public function resetActivityId()
|
||
{
|
||
$this->activityId = null;
|
||
}
|
||
|
||
public function mount()
|
||
{
|
||
$this->parameters = get_route_parameters();
|
||
$this->getContainers();
|
||
$this->loadAvailableS3Storages();
|
||
}
|
||
|
||
public function updatedDumpAll($value)
|
||
{
|
||
$morphClass = $this->resource->getMorphClass();
|
||
|
||
// Handle ServiceDatabase by checking the database type
|
||
if ($morphClass === ServiceDatabase::class) {
|
||
$dbType = $this->resource->databaseType();
|
||
if (str_contains($dbType, 'mysql')) {
|
||
$morphClass = 'mysql';
|
||
} elseif (str_contains($dbType, 'mariadb')) {
|
||
$morphClass = 'mariadb';
|
||
} elseif (str_contains($dbType, 'postgres')) {
|
||
$morphClass = 'postgresql';
|
||
}
|
||
}
|
||
|
||
switch ($morphClass) {
|
||
case StandaloneMariadb::class:
|
||
case 'mariadb':
|
||
if ($value === true) {
|
||
$this->mariadbRestoreCommand = <<<'EOD'
|
||
for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
|
||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
|
||
done && \
|
||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
|
||
mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
|
||
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
|
||
EOD;
|
||
$this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
|
||
} else {
|
||
$this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
||
}
|
||
break;
|
||
case StandaloneMysql::class:
|
||
case 'mysql':
|
||
if ($value === true) {
|
||
$this->mysqlRestoreCommand = <<<'EOD'
|
||
for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
|
||
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
|
||
done && \
|
||
mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
|
||
mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
|
||
(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
|
||
EOD;
|
||
$this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
|
||
} else {
|
||
$this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||
}
|
||
break;
|
||
case StandalonePostgresql::class:
|
||
case 'postgresql':
|
||
if ($value === true) {
|
||
$this->postgresqlRestoreCommand = <<<'EOD'
|
||
psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
|
||
psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
|
||
createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
|
||
EOD;
|
||
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||
} else {
|
||
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||
}
|
||
break;
|
||
}
|
||
|
||
}
|
||
|
||
public function getContainers()
|
||
{
|
||
$this->containers = [];
|
||
$teamId = data_get(auth()->user()->currentTeam(), 'id');
|
||
|
||
// Try to find resource by route parameter
|
||
$databaseUuid = data_get($this->parameters, 'database_uuid');
|
||
$stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
|
||
|
||
$resource = null;
|
||
if ($databaseUuid) {
|
||
// Standalone database route
|
||
$resource = getResourceByUuid($databaseUuid, $teamId);
|
||
if (is_null($resource)) {
|
||
abort(404);
|
||
}
|
||
} elseif ($stackServiceUuid) {
|
||
// ServiceDatabase route - look up the service database
|
||
$serviceUuid = data_get($this->parameters, 'service_uuid');
|
||
$project = currentTeam()
|
||
->projects()
|
||
->select('id', 'uuid', 'team_id')
|
||
->where('uuid', data_get($this->parameters, 'project_uuid'))
|
||
->firstOrFail();
|
||
$environment = $project->environments()
|
||
->select('id', 'uuid', 'name', 'project_id')
|
||
->where('uuid', data_get($this->parameters, 'environment_uuid'))
|
||
->firstOrFail();
|
||
$service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
|
||
$resource = $service->databases()->whereUuid($stackServiceUuid)->first();
|
||
if (is_null($resource)) {
|
||
abort(404);
|
||
}
|
||
} else {
|
||
abort(404);
|
||
}
|
||
|
||
$this->authorize('view', $resource);
|
||
|
||
// Store IDs for Livewire serialization
|
||
$this->resourceId = $resource->id;
|
||
$this->resourceType = get_class($resource);
|
||
|
||
// Store view-friendly properties
|
||
$this->resourceStatus = $resource->status ?? '';
|
||
|
||
// Handle ServiceDatabase server access differently
|
||
if ($resource->getMorphClass() === ServiceDatabase::class) {
|
||
$server = $resource->service?->server;
|
||
if (! $server) {
|
||
abort(404, 'Server not found for this service database.');
|
||
}
|
||
$this->serverId = $server->id;
|
||
$this->container = $resource->name.'-'.$resource->service->uuid;
|
||
$this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
|
||
|
||
// Determine database type for ServiceDatabase
|
||
$dbType = $resource->databaseType();
|
||
if (str_contains($dbType, 'postgres')) {
|
||
$this->resourceDbType = 'standalone-postgresql';
|
||
} elseif (str_contains($dbType, 'mysql')) {
|
||
$this->resourceDbType = 'standalone-mysql';
|
||
} elseif (str_contains($dbType, 'mariadb')) {
|
||
$this->resourceDbType = 'standalone-mariadb';
|
||
} elseif (str_contains($dbType, 'mongo')) {
|
||
$this->resourceDbType = 'standalone-mongodb';
|
||
} else {
|
||
$this->resourceDbType = $dbType;
|
||
}
|
||
} else {
|
||
$server = $resource->destination?->server;
|
||
if (! $server) {
|
||
abort(404, 'Server not found for this database.');
|
||
}
|
||
$this->serverId = $server->id;
|
||
$this->container = $resource->uuid;
|
||
$this->resourceUuid = $resource->uuid;
|
||
$this->resourceDbType = $resource->type();
|
||
}
|
||
|
||
if (str($resource->status)->startsWith('running')) {
|
||
$this->containers[] = $this->container;
|
||
}
|
||
|
||
if (
|
||
$resource->getMorphClass() === StandaloneRedis::class ||
|
||
$resource->getMorphClass() === StandaloneKeydb::class ||
|
||
$resource->getMorphClass() === StandaloneDragonfly::class ||
|
||
$resource->getMorphClass() === StandaloneClickhouse::class
|
||
) {
|
||
$this->unsupported = true;
|
||
}
|
||
|
||
// Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
|
||
if ($resource->getMorphClass() === ServiceDatabase::class) {
|
||
$dbType = $resource->databaseType();
|
||
if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
|
||
str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
|
||
$this->unsupported = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
public function checkFile()
|
||
{
|
||
if (filled($this->customLocation)) {
|
||
// Validate the custom location to prevent command injection
|
||
if (! $this->validateServerPath($this->customLocation)) {
|
||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||
|
||
return;
|
||
}
|
||
|
||
if (! $this->server) {
|
||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$escapedPath = escapeshellarg($this->customLocation);
|
||
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
|
||
if (blank($result)) {
|
||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||
|
||
return;
|
||
}
|
||
$this->filename = $this->customLocation;
|
||
$this->dispatch('success', 'The file exists.');
|
||
} catch (\Throwable $e) {
|
||
return handleError($e, $this);
|
||
}
|
||
}
|
||
}
|
||
|
||
public function runImport(string $password = ''): bool|string
|
||
{
|
||
if (! verifyPasswordConfirmation($password, $this)) {
|
||
return 'The provided password is incorrect.';
|
||
}
|
||
|
||
$this->authorize('update', $this->resource);
|
||
|
||
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
||
$this->dispatch('error', 'Invalid container name.');
|
||
|
||
return true;
|
||
}
|
||
|
||
if ($this->filename === '') {
|
||
$this->dispatch('error', 'Please select a file to import.');
|
||
|
||
return true;
|
||
}
|
||
|
||
if (! $this->server) {
|
||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
$this->importRunning = true;
|
||
$this->importCommands = [];
|
||
$backupFileName = "upload/{$this->resourceUuid}/restore";
|
||
|
||
// Check if an uploaded file exists first (takes priority over custom location)
|
||
if (Storage::exists($backupFileName)) {
|
||
$path = Storage::path($backupFileName);
|
||
|
||
// Reject malicious PostgreSQL payloads before transferring the file anywhere.
|
||
if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
|
||
Storage::delete($backupFileName);
|
||
$this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
|
||
|
||
return true;
|
||
}
|
||
|
||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
|
||
instant_scp($path, $tmpPath, $this->server);
|
||
Storage::delete($backupFileName);
|
||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
|
||
} elseif (filled($this->customLocation)) {
|
||
// Validate the custom location to prevent command injection
|
||
if (! $this->validateServerPath($this->customLocation)) {
|
||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
||
|
||
return true;
|
||
}
|
||
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
|
||
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
||
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
|
||
} else {
|
||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||
|
||
return true;
|
||
}
|
||
|
||
// Copy the restore command to a script file
|
||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||
|
||
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||
|
||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||
$this->importCommands[] = "chmod +x {$scriptPath}";
|
||
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||
|
||
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
|
||
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||
|
||
if (! empty($this->importCommands)) {
|
||
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
|
||
'scriptPath' => $scriptPath,
|
||
'tmpPath' => $tmpPath,
|
||
'container' => $this->container,
|
||
'serverId' => $this->server->id,
|
||
]);
|
||
|
||
// Track the activity ID
|
||
$this->activityId = $activity->id;
|
||
|
||
// Dispatch activity to the monitor and open slide-over
|
||
$this->dispatch('activityMonitor', $activity->id);
|
||
$this->dispatch('databaserestore');
|
||
}
|
||
} catch (\Throwable $e) {
|
||
handleError($e, $this);
|
||
|
||
return true;
|
||
} finally {
|
||
$this->filename = null;
|
||
$this->importCommands = [];
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
public function loadAvailableS3Storages()
|
||
{
|
||
try {
|
||
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
||
->where('is_usable', true)
|
||
->get()
|
||
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
|
||
->toArray();
|
||
} catch (\Throwable $e) {
|
||
$this->availableS3Storages = [];
|
||
}
|
||
}
|
||
|
||
public function updatedS3Path($value)
|
||
{
|
||
// Reset validation state when path changes
|
||
$this->s3FileSize = null;
|
||
|
||
// Ensure path starts with a slash
|
||
if ($value !== null && $value !== '') {
|
||
$this->s3Path = str($value)->trim()->start('/')->value();
|
||
}
|
||
}
|
||
|
||
public function updatedS3StorageId()
|
||
{
|
||
// Reset validation state when storage changes
|
||
$this->s3FileSize = null;
|
||
}
|
||
|
||
public function checkS3File()
|
||
{
|
||
if (! $this->s3StorageId) {
|
||
$this->dispatch('error', 'Please select an S3 storage.');
|
||
|
||
return;
|
||
}
|
||
|
||
if (blank($this->s3Path)) {
|
||
$this->dispatch('error', 'Please provide an S3 path.');
|
||
|
||
return;
|
||
}
|
||
|
||
// Clean the path (remove leading slash if present)
|
||
$cleanPath = ltrim($this->s3Path, '/');
|
||
|
||
// Validate the S3 path early to prevent command injection in subsequent operations
|
||
if (! $this->validateS3Path($cleanPath)) {
|
||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||
|
||
// Validate bucket name early
|
||
if (! $this->validateBucketName($s3Storage->bucket)) {
|
||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
|
||
|
||
return;
|
||
}
|
||
|
||
// Test connection
|
||
$s3Storage->testConnection();
|
||
|
||
// Build S3 disk configuration
|
||
$disk = Storage::build([
|
||
'driver' => 's3',
|
||
'region' => $s3Storage->region,
|
||
'key' => $s3Storage->key,
|
||
'secret' => $s3Storage->secret,
|
||
'bucket' => $s3Storage->bucket,
|
||
'endpoint' => $s3Storage->endpoint,
|
||
'use_path_style_endpoint' => true,
|
||
'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint),
|
||
]);
|
||
|
||
// Check if file exists
|
||
if (! $disk->exists($cleanPath)) {
|
||
$this->dispatch('error', 'File not found in S3. Please check the path.');
|
||
|
||
return;
|
||
}
|
||
|
||
// Get file size
|
||
$this->s3FileSize = $disk->size($cleanPath);
|
||
|
||
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
|
||
} catch (\Throwable $e) {
|
||
$this->s3FileSize = null;
|
||
|
||
return handleError($e, $this);
|
||
}
|
||
}
|
||
|
||
public function restoreFromS3(string $password = ''): bool|string
|
||
{
|
||
if (! verifyPasswordConfirmation($password, $this)) {
|
||
return 'The provided password is incorrect.';
|
||
}
|
||
|
||
$this->authorize('update', $this->resource);
|
||
|
||
if (! ValidationPatterns::isValidContainerName($this->container)) {
|
||
$this->dispatch('error', 'Invalid container name.');
|
||
|
||
return true;
|
||
}
|
||
|
||
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||
|
||
return true;
|
||
}
|
||
|
||
if (is_null($this->s3FileSize)) {
|
||
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||
|
||
return true;
|
||
}
|
||
|
||
if (! $this->server) {
|
||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
$this->importRunning = true;
|
||
|
||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||
|
||
$key = $s3Storage->key;
|
||
$secret = $s3Storage->secret;
|
||
$bucket = $s3Storage->bucket;
|
||
$endpoint = $s3Storage->endpoint;
|
||
|
||
// Validate bucket name to prevent command injection
|
||
if (! $this->validateBucketName($bucket)) {
|
||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
|
||
|
||
return true;
|
||
}
|
||
|
||
// Clean the S3 path
|
||
$cleanPath = ltrim($this->s3Path, '/');
|
||
|
||
// Validate the S3 path to prevent command injection
|
||
if (! $this->validateS3Path($cleanPath)) {
|
||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||
|
||
return true;
|
||
}
|
||
|
||
// Get helper image
|
||
$helperImage = coolifyHelperImage();
|
||
$latestVersion = getHelperVersion();
|
||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||
|
||
// Get the database destination network
|
||
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
|
||
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
|
||
} else {
|
||
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||
}
|
||
|
||
// Generate unique names for this operation
|
||
$containerName = "s3-restore-{$this->resourceUuid}";
|
||
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
|
||
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
|
||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||
|
||
$escapedServerTmpPath = escapeshellarg($serverTmpPath);
|
||
$escapedContainerTmpPath = escapeshellarg($containerTmpPath);
|
||
$escapedScriptPath = escapeshellarg($scriptPath);
|
||
$escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
|
||
$escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
|
||
$escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
|
||
$restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
|
||
|
||
// Prepare all commands in sequence
|
||
$commands = [];
|
||
|
||
// 1. Clean up any existing helper container and temp files from previous runs
|
||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
|
||
$commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
|
||
|
||
// 2. Start helper container on the database network
|
||
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
|
||
|
||
// 3. Configure S3 access in helper container
|
||
$escapedEndpoint = escapeshellarg($endpoint);
|
||
$escapedKey = escapeshellarg($key);
|
||
$escapedSecret = escapeshellarg($secret);
|
||
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||
|
||
// 4. Check file exists in S3 (bucket and path already validated above)
|
||
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
|
||
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
|
||
|
||
// 5. Download from S3 to helper container (progress shown by default)
|
||
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
|
||
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
|
||
|
||
// 6. Copy from helper to server, then immediately to database container
|
||
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
|
||
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
|
||
$this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
|
||
|
||
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
|
||
|
||
// 8. Build and execute restore command inside database container
|
||
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
|
||
|
||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
|
||
$commands[] = "chmod +x {$escapedScriptPath}";
|
||
$commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
|
||
|
||
// 9. Execute restore and cleanup temp files immediately after completion
|
||
$commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
|
||
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||
|
||
// Execute all commands with cleanup event (as safety net for edge cases)
|
||
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
||
'containerName' => $containerName,
|
||
'serverTmpPath' => $serverTmpPath,
|
||
'scriptPath' => $scriptPath,
|
||
'containerTmpPath' => $containerTmpPath,
|
||
'container' => $this->container,
|
||
'serverId' => $this->server->id,
|
||
]);
|
||
|
||
// Track the activity ID
|
||
$this->activityId = $activity->id;
|
||
|
||
// Dispatch activity to the monitor and open slide-over
|
||
$this->dispatch('activityMonitor', $activity->id);
|
||
$this->dispatch('databaserestore');
|
||
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
||
} catch (\Throwable $e) {
|
||
$this->importRunning = false;
|
||
handleError($e, $this);
|
||
|
||
return true;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
|
||
{
|
||
$script = $this->buildPostgresRestoreScanScript($tmpPath);
|
||
|
||
if ($script === null) {
|
||
return null;
|
||
}
|
||
|
||
return "docker exec {$this->container} sh -c ".escapeshellarg($script);
|
||
}
|
||
|
||
/**
|
||
* Build the POSIX shell snippet that aborts (exit 1) when a PostgreSQL
|
||
* backup contains directives leading to OS command execution.
|
||
*
|
||
* Hardened against bypasses:
|
||
* - decompresses gzip backups before scanning,
|
||
* - strips `--` line comments and flattens newlines so multi-line and
|
||
* comment-separated payloads (e.g. `FROM/**/PROGRAM`) are caught,
|
||
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
|
||
*/
|
||
public function buildPostgresRestoreScanScript(string $tmpPath): ?string
|
||
{
|
||
if (! $this->isPostgresqlRestore()) {
|
||
return null;
|
||
}
|
||
|
||
$escapedTmpPath = escapeshellarg($tmpPath);
|
||
|
||
// Token separator PostgreSQL treats as whitespace: real whitespace or a
|
||
// /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
|
||
$sep = '([[:space:]]|/\\*[^*]*\\*/)';
|
||
|
||
$pattern = implode('|', [
|
||
"copy{$sep}+[^;]*(from|to){$sep}+program",
|
||
'(^|[[:space:]])\\\\!',
|
||
"(^|[[:space:]])\\\\(o|g){$sep}*\\|",
|
||
]);
|
||
$escapedPattern = escapeshellarg($pattern);
|
||
|
||
return "if (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
|
||
}
|
||
|
||
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
|
||
{
|
||
$command = $this->buildRestoreSafetyCheckCommand($tmpPath);
|
||
|
||
if ($command !== null) {
|
||
$commands[] = $command;
|
||
}
|
||
}
|
||
|
||
private function isPostgresqlRestore(): bool
|
||
{
|
||
$morphClass = $this->resource->getMorphClass();
|
||
|
||
if ($morphClass === ServiceDatabase::class) {
|
||
return str_contains($this->resource->databaseType(), 'postgres');
|
||
}
|
||
|
||
return $morphClass === StandalonePostgresql::class || $morphClass === 'postgresql';
|
||
}
|
||
|
||
public function buildRestoreCommand(string $tmpPath): string
|
||
{
|
||
$escapedTmpPath = escapeshellarg($tmpPath);
|
||
$morphClass = $this->resource->getMorphClass();
|
||
|
||
// Handle ServiceDatabase by checking the database type
|
||
if ($morphClass === ServiceDatabase::class) {
|
||
$dbType = $this->resource->databaseType();
|
||
if (str_contains($dbType, 'mysql')) {
|
||
$morphClass = 'mysql';
|
||
} elseif (str_contains($dbType, 'mariadb')) {
|
||
$morphClass = 'mariadb';
|
||
} elseif (str_contains($dbType, 'postgres')) {
|
||
$morphClass = 'postgresql';
|
||
} elseif (str_contains($dbType, 'mongo')) {
|
||
$morphClass = 'mongodb';
|
||
}
|
||
}
|
||
|
||
switch ($morphClass) {
|
||
case StandaloneMariadb::class:
|
||
case 'mariadb':
|
||
$restoreCommand = $this->mariadbRestoreCommand;
|
||
if ($this->dumpAll) {
|
||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
|
||
} else {
|
||
$restoreCommand .= " < {$escapedTmpPath}";
|
||
}
|
||
break;
|
||
case StandaloneMysql::class:
|
||
case 'mysql':
|
||
$restoreCommand = $this->mysqlRestoreCommand;
|
||
if ($this->dumpAll) {
|
||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
|
||
} else {
|
||
$restoreCommand .= " < {$escapedTmpPath}";
|
||
}
|
||
break;
|
||
case StandalonePostgresql::class:
|
||
case 'postgresql':
|
||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||
if ($this->dumpAll) {
|
||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
|
||
} else {
|
||
$restoreCommand .= " {$escapedTmpPath}";
|
||
}
|
||
break;
|
||
case StandaloneMongodb::class:
|
||
case 'mongodb':
|
||
$restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
|
||
break;
|
||
default:
|
||
$restoreCommand = '';
|
||
}
|
||
|
||
return $restoreCommand;
|
||
}
|
||
}
|