diff --git a/.github/workflows/cleanup-ghcr-untagged.yml b/.github/workflows/cleanup-ghcr-untagged.yml new file mode 100644 index 000000000..1ad41ce16 --- /dev/null +++ b/.github/workflows/cleanup-ghcr-untagged.yml @@ -0,0 +1,24 @@ +name: Cleanup Untagged GHCR Images + +on: + workflow_dispatch: # Allow manual trigger + schedule: + - cron: '0 */6 * * *' # Run every 6 hours to handle large volume (16k+ images) + +env: + GITHUB_REGISTRY: ghcr.io + +jobs: + cleanup-testing-host: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Delete untagged coolify-testing-host images + uses: actions/delete-package-versions@v5 + with: + package-name: 'coolify-testing-host' + package-type: 'container' + min-versions-to-keep: 0 + delete-only-untagged-versions: 'true' diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 7b85408cf..46282fddb 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -597,6 +597,224 @@ public function update_by_uuid(Request $request) ]); } + #[OA\Post( + summary: 'Create Backup', + description: 'Create a new scheduled backup configuration for a database', + path: '/databases/{uuid}/backups', + operationId: 'create-database-backup', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Backup configuration data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['frequency'], + properties: [ + 'frequency' => ['type' => 'string', 'description' => 'Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)'], + 'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true], + 'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false], + 's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'], + 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], + 'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false], + 'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'], + 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'], + 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'], + 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'], + 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], + 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], + 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Backup configuration created successfully', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'format' => 'uuid', 'example' => '550e8400-e29b-41d4-a716-446655440000'], + 'message' => ['type' => 'string', 'example' => 'Backup configuration created successfully.'], + ] + ) + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function create_backup(Request $request) + { + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + // Validate incoming request is valid JSON + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'frequency' => 'required|string', + 'enabled' => 'boolean', + 'save_s3' => 'boolean', + 'dump_all' => 'boolean', + 'backup_now' => 'boolean|nullable', + 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', + 'databases_to_backup' => 'string|nullable', + 'database_backup_retention_amount_locally' => 'integer|min:0', + 'database_backup_retention_days_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_amount_s3' => 'integer|min:0', + 'database_backup_retention_days_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'integer|min:0', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + if (! $request->uuid) { + return response()->json(['message' => 'UUID is required.'], 404); + } + + $uuid = $request->uuid; + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('manageBackups', $database); + + // Validate frequency is a valid cron expression + $isValid = validate_cron_expression($request->frequency); + if (! $isValid) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + + // Validate S3 storage if save_s3 is true + if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']], + ], 422); + } + + if ($request->filled('s3_storage_uuid')) { + $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); + if (! $existsInTeam) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], + ], 422); + } + } + + // Check for extra fields + $extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']); + if (! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $backupData = $request->only($backupConfigFields); + + // Convert s3_storage_uuid to s3_storage_id + if (isset($backupData['s3_storage_uuid'])) { + $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + if ($s3Storage) { + $backupData['s3_storage_id'] = $s3Storage->id; + } elseif ($request->boolean('save_s3')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], + ], 422); + } + unset($backupData['s3_storage_uuid']); + } + + // Set default databases_to_backup based on database type if not provided + if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) { + if ($database->type() === 'standalone-postgresql') { + $backupData['databases_to_backup'] = $database->postgres_db; + } elseif ($database->type() === 'standalone-mysql') { + $backupData['databases_to_backup'] = $database->mysql_database; + } elseif ($database->type() === 'standalone-mariadb') { + $backupData['databases_to_backup'] = $database->mariadb_database; + } + } + + // Add required fields + $backupData['database_id'] = $database->id; + $backupData['database_type'] = $database->getMorphClass(); + $backupData['team_id'] = $teamId; + + // Set defaults + if (! isset($backupData['enabled'])) { + $backupData['enabled'] = true; + } + + $backupConfig = ScheduledDatabaseBackup::create($backupData); + + // Trigger immediate backup if requested + if ($request->backup_now) { + dispatch(new DatabaseBackupJob($backupConfig)); + } + + return response()->json([ + 'uuid' => $backupConfig->uuid, + 'message' => 'Backup configuration created successfully.', + ], 201); + } + #[OA\Patch( summary: 'Update', description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID', diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index c4d603392..16a7b6f71 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -131,6 +131,161 @@ public function deployment_by_uuid(Request $request) return response()->json($this->removeSensitiveData($deployment)); } + #[OA\Post( + summary: 'Cancel', + description: 'Cancel a deployment by UUID.', + path: '/deployments/{uuid}/cancel', + operationId: 'cancel-deployment-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Deployments'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Deployment cancelled successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Deployment cancelled successfully.'], + 'deployment_uuid' => ['type' => 'string', 'example' => 'cm37r6cqj000008jm0veg5tkm'], + 'status' => ['type' => 'string', 'example' => 'cancelled-by-user'], + ] + ) + ), + ]), + new OA\Response( + response: 400, + description: 'Deployment cannot be cancelled (already finished/failed/cancelled).', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Deployment cannot be cancelled. Current status: finished'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 403, + description: 'User doesn\'t have permission to cancel this deployment.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'You do not have permission to cancel this deployment.'], + ] + ) + ), + ]), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function cancel_deployment(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + + // Find the deployment by UUID + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', $uuid)->first(); + if (! $deployment) { + return response()->json(['message' => 'Deployment not found.'], 404); + } + + // Check if the deployment belongs to the user's team + $servers = Server::whereTeamId($teamId)->pluck('id'); + if (! $servers->contains($deployment->server_id)) { + return response()->json(['message' => 'You do not have permission to cancel this deployment.'], 403); + } + + // Check if deployment can be cancelled (must be queued or in_progress) + $cancellableStatuses = [ + \App\Enums\ApplicationDeploymentStatus::QUEUED->value, + \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, + ]; + + if (! in_array($deployment->status, $cancellableStatuses)) { + return response()->json([ + 'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}", + ], 400); + } + + // Perform the cancellation + try { + $deployment_uuid = $deployment->deployment_uuid; + $kill_command = "docker rm -f {$deployment_uuid}"; + $build_server_id = $deployment->build_server_id ?? $deployment->server_id; + + // Mark deployment as cancelled + $deployment->update([ + 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + + // Get the server + $server = Server::find($build_server_id); + + if ($server) { + // Add cancellation log entry + $deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); + + // Check if container exists and kill it + $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + instant_remote_process([$kill_command], $server); + $deployment->addLogEntry('Deployment container stopped.'); + } else { + $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); + } + + // Kill running process if process ID exists + if ($deployment->current_process_id) { + try { + $processKillCommand = "kill -9 {$deployment->current_process_id}"; + instant_remote_process([$processKillCommand], $server); + } catch (\Throwable $e) { + // Process might already be gone + } + } + } + + return response()->json([ + 'message' => 'Deployment cancelled successfully.', + 'deployment_uuid' => $deployment->deployment_uuid, + 'status' => $deployment->status, + ]); + } catch (\Throwable $e) { + return response()->json([ + 'message' => 'Failed to cancel deployment: '.$e->getMessage(), + ], 500); + } + } + #[OA\Get( summary: 'Deploy', description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.', diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 7ddbaf991..f6a6b3513 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -12,6 +12,88 @@ class GithubController extends Controller { + private function removeSensitiveData($githubApp) + { + $githubApp->makeHidden([ + 'client_secret', + 'webhook_secret', + ]); + + return serializeApiResponse($githubApp); + } + + #[OA\Get( + summary: 'List', + description: 'List all GitHub apps.', + path: '/github-apps', + operationId: 'list-github-apps', + security: [ + ['bearerAuth' => []], + ], + tags: ['GitHub Apps'], + responses: [ + new OA\Response( + response: 200, + description: 'List of GitHub apps.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'organization' => ['type' => 'string', 'nullable' => true], + 'api_url' => ['type' => 'string'], + 'html_url' => ['type' => 'string'], + 'custom_user' => ['type' => 'string'], + 'custom_port' => ['type' => 'integer'], + 'app_id' => ['type' => 'integer'], + 'installation_id' => ['type' => 'integer'], + 'client_id' => ['type' => 'string'], + 'private_key_id' => ['type' => 'integer'], + 'is_system_wide' => ['type' => 'boolean'], + 'is_public' => ['type' => 'boolean'], + 'team_id' => ['type' => 'integer'], + 'type' => ['type' => 'string'], + ] + ) + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function list_github_apps(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $githubApps = GithubApp::where(function ($query) use ($teamId) { + $query->where('team_id', $teamId) + ->orWhere('is_system_wide', true); + })->get(); + + $githubApps = $githubApps->map(function ($app) { + return $this->removeSensitiveData($app); + }); + + return response()->json($githubApps); + } + #[OA\Post( summary: 'Create GitHub App', description: 'Create a new GitHub app.', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 737724d22..b3565a933 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -328,9 +328,23 @@ public function create_service(Request $request) }); } 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), - 'docker_compose_raw' => base64_decode($oneClickService), + 'docker_compose_raw' => $dockerComposeRaw, 'environment_id' => $environment->id, 'service_type' => $oneClickServiceName, 'server_id' => $server->id, @@ -338,9 +352,9 @@ public function create_service(Request $request) 'destination_type' => $destination->getMorphClass(), ]; 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->save(); if ($oneClickDotEnvs?->count() > 0) { @@ -462,6 +476,18 @@ public function create_service(Request $request) $dockerCompose = base64_decode($request->docker_compose_raw); $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; $instantDeploy = $request->instant_deploy ?? false; @@ -777,6 +803,19 @@ public function update_by_uuid(Request $request) } $dockerCompose = base64_decode($request->docker_compose_raw); $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; } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index e9d7b82b2..515d40c62 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -14,7 +14,7 @@ class Kernel extends HttpKernel * @var array */ protected $middleware = [ - // \App\Http\Middleware\TrustHosts::class, + \App\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustProxies::class, \Illuminate\Http\Middleware\HandleCors::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class, diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index c9c58bddc..c2a2cb41a 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -2,7 +2,10 @@ namespace App\Http\Middleware; +use App\Models\InstanceSettings; use Illuminate\Http\Middleware\TrustHosts as Middleware; +use Illuminate\Support\Facades\Cache; +use Spatie\Url\Url; class TrustHosts extends Middleware { @@ -13,8 +16,37 @@ class TrustHosts extends Middleware */ public function hosts(): array { - return [ - $this->allSubdomainsOfApplicationUrl(), - ]; + $trustedHosts = []; + + // 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); } } diff --git a/app/Jobs/ApplicationPullRequestUpdateJob.php b/app/Jobs/ApplicationPullRequestUpdateJob.php index a92f53b39..d2e3cc964 100755 --- a/app/Jobs/ApplicationPullRequestUpdateJob.php +++ b/app/Jobs/ApplicationPullRequestUpdateJob.php @@ -35,6 +35,9 @@ public function handle() if ($this->application->is_public_repository()) { return; } + + $serviceName = $this->application->name; + if ($this->status === ProcessStatus::CLOSED) { $this->delete_comment(); @@ -42,12 +45,12 @@ public function handle() } match ($this->status) { - ProcessStatus::QUEUED => $this->body = "The preview deployment is queued. ⏳\n\n", - ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment is in progress. 🟡\n\n", - ProcessStatus::FINISHED => $this->body = "The preview deployment is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''), - ProcessStatus::ERROR => $this->body = "The preview deployment failed. 🔴\n\n", - ProcessStatus::KILLED => $this->body = "The preview deployment was killed. ⚫\n\n", - ProcessStatus::CANCELLED => $this->body = "The preview deployment was cancelled. 🚫\n\n", + 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}"; diff --git a/app/Livewire/MonacoEditor.php b/app/Livewire/MonacoEditor.php index 53ca1d386..54f0965a2 100644 --- a/app/Livewire/MonacoEditor.php +++ b/app/Livewire/MonacoEditor.php @@ -25,6 +25,7 @@ public function __construct( public bool $readonly, public bool $allowTab, public bool $spellcheck, + public bool $autofocus = false, public ?string $helper, public bool $realtimeValidation, public bool $allowToPeak, diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 5cda1dedd..a88a62d88 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -37,6 +37,10 @@ public function submit() 'dockerComposeRaw' => 'required', ]); $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(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 1961a7985..a0d2699ba 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -101,6 +101,10 @@ public function submit($notify = true) { try { $this->validate(); + + // Validate for command injection BEFORE saving to database + validateDockerComposeForInjection($this->service->docker_compose_raw); + $this->service->save(); $this->service->saveExtraFields($this->fields); $this->service->parse(); diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 45f7e467f..45af53950 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -45,9 +45,16 @@ private function generateInviteLink(bool $sendEmail = false) try { $this->authorize('manageInvitations', currentTeam()); $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.'); } + $this->email = strtolower($this->email); $member_emails = currentTeam()->members()->get()->pluck('email'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 28ea17db2..9554d71a7 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1109,7 +1109,7 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ // 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_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} '{$escapedCustomRepository}'"; + $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) { $commands = collect([ @@ -1126,9 +1126,9 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ } if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $base_comamnd)); + $commands->push(executeInDocker($deployment_uuid, $base_command)); } else { - $commands->push($base_comamnd); + $commands->push($base_command); } return [ diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index ac95bb8a9..cd1c05de4 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -35,13 +35,18 @@ class InstanceSettings extends Model protected static function booted(): void { static::updated(function ($settings) { - if ($settings->isDirty('helper_version')) { + if ($settings->wasChanged('helper_version')) { Server::chunkById(100, function ($servers) { foreach ($servers as $server) { PullHelperImageJob::dispatch($server); } }); } + + // Clear trusted hosts cache when FQDN changes + if ($settings->wasChanged('fqdn')) { + \Cache::forget('instance_settings_fqdn_host'); + } }); } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 3abd55e9c..6da4dd4c6 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -79,11 +79,11 @@ protected static function booted() }); static::updated(function ($settings) { if ( - $settings->isDirty('sentinel_token') || - $settings->isDirty('sentinel_custom_url') || - $settings->isDirty('sentinel_metrics_refresh_rate_seconds') || - $settings->isDirty('sentinel_metrics_history_days') || - $settings->isDirty('sentinel_push_interval_seconds') + $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') ) { $settings->server->restartSentinel(); } diff --git a/app/Policies/TeamPolicy.php b/app/Policies/TeamPolicy.php index b7ef48943..849e23751 100644 --- a/app/Policies/TeamPolicy.php +++ b/app/Policies/TeamPolicy.php @@ -42,8 +42,7 @@ public function update(User $user, Team $team): bool return false; } - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); } /** @@ -56,8 +55,7 @@ public function delete(User $user, Team $team): bool return false; } - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); } /** @@ -70,8 +68,7 @@ public function manageMembers(User $user, Team $team): bool return false; } - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); } /** @@ -84,8 +81,7 @@ public function viewAdmin(User $user, Team $team): bool return false; } - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); } /** @@ -98,7 +94,6 @@ public function manageInvitations(User $user, Team $team): bool return false; } - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); } } diff --git a/app/Traits/DeletesUserSessions.php b/app/Traits/DeletesUserSessions.php index a4d3a7cfd..e9ec0d946 100644 --- a/app/Traits/DeletesUserSessions.php +++ b/app/Traits/DeletesUserSessions.php @@ -26,7 +26,7 @@ protected static function bootDeletesUserSessions() { static::updated(function ($user) { // Check if password was changed - if ($user->isDirty('password')) { + if ($user->wasChanged('password')) { $user->deleteAllSessions(); } }); diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php index 3148d2566..abf98e6df 100644 --- a/app/View/Components/Forms/Textarea.php +++ b/app/View/Components/Forms/Textarea.php @@ -27,6 +27,7 @@ public function __construct( public bool $readonly = false, public bool $allowTab = false, public bool $spellcheck = false, + public bool $autofocus = false, public ?string $helper = null, public bool $realtimeValidation = false, public bool $allowToPeak = true, diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 5f87260d1..d6c9b5bdf 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -378,6 +378,16 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ if ($serviceLabels) { $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)) { return $matches[1]; } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 09d4c7549..f2260f0c6 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -16,6 +16,101 @@ use Symfony\Component\Yaml\Yaml; 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 { $volumeString = trim($volumeString); @@ -212,6 +307,46 @@ function parseDockerVolumeString(string $volumeString): array // 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 [ 'source' => $source !== null ? str($source) : null, 'target' => $target !== null ? str($target) : null, @@ -265,6 +400,16 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $allMagicEnvironments = collect([]); 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([]); $image = data_get_str($service, 'image'); $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'); $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(); if ($foundConfig) { $contentNotNull_temp = data_get($foundConfig, 'content'); @@ -1178,6 +1350,16 @@ function serviceParser(Service $resource): Collection $allMagicEnvironments = collect([]); // Presave services 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'); $isDatabase = isDatabaseImage($image, $service); if ($isDatabase) { @@ -1575,6 +1757,33 @@ function serviceParser(Service $resource): Collection $content = data_get($volume, 'content'); $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(); if ($foundConfig) { $contentNotNull_temp = data_get($foundConfig, 'content'); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 308f522fb..0f5b6f553 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -104,6 +104,48 @@ function sanitize_string(?string $input = null): ?string 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 { $name = sanitize_string($name); @@ -1285,6 +1327,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($serviceLabels->count() > 0) { $removedLabels = collect([]); $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('=')) { $removedLabels->put($serviceLabelName, $serviceLabel); @@ -1294,6 +1342,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal return $serviceLabel; }); foreach ($removedLabels as $removedLabelName => $removedLabel) { + // Convert array values to strings + if (is_array($removedLabel)) { + $removedLabel = (string) collect($removedLabel)->first(); + } $serviceLabels->push("$removedLabelName=$removedLabel"); } } @@ -2005,6 +2057,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($serviceLabels->count() > 0) { $removedLabels = collect([]); $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('=')) { $removedLabels->put($serviceLabelName, $serviceLabel); @@ -2014,6 +2072,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal return $serviceLabel; }); foreach ($removedLabels as $removedLabelName => $removedLabel) { + // Convert array values to strings + if (is_array($removedLabel)) { + $removedLabel = (string) collect($removedLabel)->first(); + } $serviceLabels->push("$removedLabelName=$removedLabel"); } } diff --git a/config/constants.php b/config/constants.php index 01eaa7fa1..1fc1af3f3 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.435', + 'version' => '4.0.0-beta.436', 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 2d6f52e31..f012c1534 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -14,6 +14,21 @@ class ApplicationSeeder extends Seeder */ public function run(): void { + Application::create([ + 'name' => 'Docker Compose Example', + 'repository_project_id' => 603035348, + 'git_repository' => 'coollabsio/coolify-examples', + 'git_branch' => 'v4.x', + 'base_directory' => '/docker-compose', + 'docker_compose_location' => 'docker-compose-test.yaml', + 'build_pack' => 'dockercompose', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GithubApp::class, + ]); Application::create([ 'name' => 'NodeJS Fastify Example', 'fqdn' => 'http://nodejs.127.0.0.1.sslip.io', diff --git a/database/seeders/InstanceSettingsSeeder.php b/database/seeders/InstanceSettingsSeeder.php index 7f2deb3a6..baa7abffc 100644 --- a/database/seeders/InstanceSettingsSeeder.php +++ b/database/seeders/InstanceSettingsSeeder.php @@ -16,6 +16,7 @@ public function run(): void InstanceSettings::create([ 'id' => 0, 'is_registration_enabled' => true, + 'is_api_enabled' => isDev(), 'smtp_enabled' => true, 'smtp_host' => 'coolify-mail', 'smtp_port' => 1025, diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 1a95de03a..0bced1ece 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -32,7 +32,7 @@ @utility apexcharts-tooltip-custom-title { } @utility input-sticky { - @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 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 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 focus-visible:outline-none focus-visible:border-l-4 focus-visible:border-l-coollabs dark:focus-visible:border-l-warning; } @utility input-sticky-active { @@ -46,20 +46,20 @@ @utility input-focus { /* input, select before */ @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 */ @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 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 { @apply w-full; @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 { diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php index 7f9ffefec..510f4adcc 100644 --- a/resources/views/components/forms/datalist.blade.php +++ b/resources/views/components/forms/datalist.blade.php @@ -98,12 +98,12 @@ {{-- Unified Input Container with Tags Inside --}}
+ wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"> {{-- Selected Tags Inside Input --}}