diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 000000000..8c6715a15 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.cursor/rules/README.mdc b/.cursor/rules/README.mdc index 3eb1c56fb..07f19a816 100644 --- a/.cursor/rules/README.mdc +++ b/.cursor/rules/README.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: Complete guide to Coolify Cursor rules and development patterns +globs: .cursor/rules/*.mdc alwaysApply: false --- # Coolify Cursor Rules - Complete Guide @@ -18,6 +18,7 @@ This comprehensive set of Cursor Rules provides deep insights into **Coolify**, ### 🎨 Frontend Development - **[frontend-patterns.mdc](mdc:.cursor/rules/frontend-patterns.mdc)** - Livewire + Alpine.js + Tailwind architecture +- **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)** - Enhanced form components with built-in authorization ### 🗄️ Data & Backend - **[database-patterns.mdc](mdc:.cursor/rules/database-patterns.mdc)** - Database architecture, models, and data management diff --git a/.cursor/rules/api-and-routing.mdc b/.cursor/rules/api-and-routing.mdc index 21daf22d2..8321205ac 100644 --- a/.cursor/rules/api-and-routing.mdc +++ b/.cursor/rules/api-and-routing.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: RESTful API design, routing patterns, webhooks, and HTTP communication +globs: routes/*.php, app/Http/Controllers/**/*.php, app/Http/Resources/*.php, app/Http/Requests/*.php alwaysApply: false --- # Coolify API & Routing Architecture diff --git a/.cursor/rules/application-architecture.mdc b/.cursor/rules/application-architecture.mdc index 162c0840f..ef8d549ad 100644 --- a/.cursor/rules/application-architecture.mdc +++ b/.cursor/rules/application-architecture.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: Laravel application structure, patterns, and architectural decisions +globs: app/**/*.php, config/*.php, bootstrap/**/*.php alwaysApply: false --- # Coolify Application Architecture diff --git a/.cursor/rules/database-patterns.mdc b/.cursor/rules/database-patterns.mdc index 58934598b..a4f65f5fb 100644 --- a/.cursor/rules/database-patterns.mdc +++ b/.cursor/rules/database-patterns.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: Database architecture, models, migrations, relationships, and data management patterns +globs: app/Models/*.php, database/migrations/*.php, database/seeders/*.php, app/Actions/Database/*.php alwaysApply: false --- # Coolify Database Architecture & Patterns diff --git a/.cursor/rules/deployment-architecture.mdc b/.cursor/rules/deployment-architecture.mdc index 5174cbb99..35ae6699b 100644 --- a/.cursor/rules/deployment-architecture.mdc +++ b/.cursor/rules/deployment-architecture.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: Docker orchestration, deployment workflows, and containerization patterns +globs: app/Jobs/*.php, app/Actions/Application/*.php, app/Actions/Server/*.php, docker/*.*, *.yml, *.yaml alwaysApply: false --- # Coolify Deployment Architecture diff --git a/.cursor/rules/development-workflow.mdc b/.cursor/rules/development-workflow.mdc index dd38cbc3f..175b7d85a 100644 --- a/.cursor/rules/development-workflow.mdc +++ b/.cursor/rules/development-workflow.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: Development setup, coding standards, contribution guidelines, and best practices +globs: **/*.php, composer.json, package.json, *.md, .env.example alwaysApply: false --- # Coolify Development Workflow diff --git a/.cursor/rules/form-components.mdc b/.cursor/rules/form-components.mdc new file mode 100644 index 000000000..665ccfd98 --- /dev/null +++ b/.cursor/rules/form-components.mdc @@ -0,0 +1,452 @@ +--- +description: Enhanced form components with built-in authorization system +globs: resources/views/**/*.blade.php, app/View/Components/Forms/*.php +alwaysApply: true +--- + +# Enhanced Form Components with Authorization + +## Overview + +Coolify's form components now feature **built-in authorization** that automatically handles permission-based UI control, dramatically reducing code duplication and improving security consistency. + +## Enhanced Components + +All form components now support the `canGate` authorization system: + +- **[Input.php](mdc:app/View/Components/Forms/Input.php)** - Text, password, and other input fields +- **[Select.php](mdc:app/View/Components/Forms/Select.php)** - Dropdown selection components +- **[Textarea.php](mdc:app/View/Components/Forms/Textarea.php)** - Multi-line text areas +- **[Checkbox.php](mdc:app/View/Components/Forms/Checkbox.php)** - Boolean toggle components +- **[Button.php](mdc:app/View/Components/Forms/Button.php)** - Action buttons + +## Authorization Parameters + +### Core Parameters +```php +public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete' +public mixed $canResource = null; // Resource model instance to check against +public bool $autoDisable = true; // Automatically disable if no permission +``` + +### How It Works +```php +// Automatic authorization logic in each component +if ($this->canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (! $hasPermission) { + $this->disabled = true; + // For Checkbox: also sets $this->instantSave = false; + } +} +``` + +## Usage Patterns + +### ✅ Recommended: Single Line Pattern + +**Before (Verbose, 6+ lines per element):** +```html +@can('update', $application) + + + Save +@else + + +@endcan +``` + +**After (Clean, 1 line per element):** +```html + + +Save +``` + +**Result: 90% code reduction!** + +### Component-Specific Examples + +#### Input Fields +```html + + + + + + + + +``` + +#### Select Dropdowns +```html + + + + + + + + + + @foreach($servers as $server) + + @endforeach + +``` + +#### Checkboxes with InstantSave +```html + + + + + + + + +``` + +#### Textareas +```html + + + + + +``` + +#### Buttons +```html + + + Save Configuration + + + + + Deploy Application + + + + + Delete Application + +``` + +## Advanced Usage + +### Custom Authorization Logic +```html + + +``` + +### Multiple Permission Checks +```html + + +``` + +### Conditional Resources +```html + + + {{ $isEditing ? 'Save Changes' : 'View Details' }} + +``` + +## Supported Gates + +### Resource-Level Gates +- `view` - Read access to resource details +- `update` - Modify resource configuration and settings +- `deploy` - Deploy, restart, or manage resource state +- `delete` - Remove or destroy resource +- `clone` - Duplicate resource to another location + +### Global Gates +- `createAnyResource` - Create new resources of any type +- `manageTeam` - Team administration permissions +- `accessServer` - Server-level access permissions + +## Supported Resources + +### Primary Resources +- `$application` - Application instances and configurations +- `$service` - Docker Compose services and components +- `$database` - Database instances (PostgreSQL, MySQL, etc.) +- `$server` - Physical or virtual server instances + +### Container Resources +- `$project` - Project containers and environments +- `$environment` - Environment-specific configurations +- `$team` - Team and organization contexts + +### Infrastructure Resources +- `$privateKey` - SSH private keys and certificates +- `$source` - Git sources and repositories +- `$destination` - Deployment destinations and targets + +## Component Behavior + +### Input Components (Input, Select, Textarea) +When authorization fails: +- **disabled = true** - Field becomes non-editable +- **Visual styling** - Opacity reduction and disabled cursor +- **Form submission** - Values are ignored in forms +- **User feedback** - Clear visual indication of restricted access + +### Checkbox Components +When authorization fails: +- **disabled = true** - Checkbox becomes non-clickable +- **instantSave = false** - Automatic saving is disabled +- **State preservation** - Current value is maintained but read-only +- **Visual styling** - Disabled appearance with reduced opacity + +### Button Components +When authorization fails: +- **disabled = true** - Button becomes non-clickable +- **Event blocking** - Click handlers are ignored +- **Visual styling** - Disabled appearance and cursor +- **Loading states** - Loading indicators are disabled + +## Migration Guide + +### Converting Existing Forms + +**Old Pattern:** +```html +
+ @can('update', $application) + + ... + + Save + @else + + ... + + @endcan + +``` + +**New Pattern:** +```html +
+ + ... + + Save + +``` + +### Gradual Migration Strategy + +1. **Start with new forms** - Use the new pattern for all new components +2. **Convert high-traffic areas** - Migrate frequently used forms first +3. **Batch convert similar forms** - Group similar authorization patterns +4. **Test thoroughly** - Verify authorization behavior matches expectations +5. **Remove old patterns** - Clean up legacy @can/@else blocks + +## Testing Patterns + +### Component Authorization Tests +```php +// Test authorization integration in components +test('input component respects authorization', function () { + $user = User::factory()->member()->create(); + $application = Application::factory()->create(); + + // Member should see disabled input + $component = Livewire::actingAs($user) + ->test(TestComponent::class, [ + 'canGate' => 'update', + 'canResource' => $application + ]); + + expect($component->get('disabled'))->toBeTrue(); +}); + +test('checkbox disables instantSave for unauthorized users', function () { + $user = User::factory()->member()->create(); + $application = Application::factory()->create(); + + $component = Livewire::actingAs($user) + ->test(CheckboxComponent::class, [ + 'instantSave' => true, + 'canGate' => 'update', + 'canResource' => $application + ]); + + expect($component->get('disabled'))->toBeTrue(); + expect($component->get('instantSave'))->toBeFalse(); +}); +``` + +### Integration Tests +```php +// Test full form authorization behavior +test('application form respects member permissions', function () { + $member = User::factory()->member()->create(); + $application = Application::factory()->create(); + + $this->actingAs($member) + ->get(route('application.edit', $application)) + ->assertSee('disabled') + ->assertDontSee('Save Configuration'); +}); +``` + +## Best Practices + +### Consistent Gate Usage +- Use `update` for configuration changes +- Use `deploy` for operational actions +- Use `view` for read-only access +- Use `delete` for destructive actions + +### Resource Context +- Always pass the specific resource being acted upon +- Use team context for creation permissions +- Consider nested resource relationships + +### Error Handling +- Provide clear feedback for disabled components +- Use helper text to explain permission requirements +- Consider tooltips for disabled buttons + +### Performance +- Authorization checks are cached per request +- Use eager loading for resource relationships +- Consider query optimization for complex permissions + +## Common Patterns + +### Application Configuration Forms +```html + + +... + +Save +``` + +### Service Configuration Forms +```html + + + + +Save + + + + + +@can('update', $service) + +@endcan +``` + +### Server Management Forms +```html + + +... +Delete Server +``` + +### Resource Creation Forms +```html + + +... +Create Application +``` \ No newline at end of file diff --git a/.cursor/rules/frontend-patterns.mdc b/.cursor/rules/frontend-patterns.mdc index 45888eee4..663490d3b 100644 --- a/.cursor/rules/frontend-patterns.mdc +++ b/.cursor/rules/frontend-patterns.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: Livewire components, Alpine.js patterns, Tailwind CSS, and enhanced form components +globs: app/Livewire/**/*.php, resources/views/**/*.blade.php, resources/js/**/*.js, resources/css/**/*.css alwaysApply: false --- # Coolify Frontend Architecture & Patterns @@ -230,6 +230,41 @@ class ServerList extends Component - **Asset bundling** and compression - **CDN integration** for static assets +## Enhanced Form Components + +### Built-in Authorization System +Coolify features **enhanced form components** with automatic authorization handling: + +```html + + + +Save + + +@can('update', $application) + +@else + +@endcan +``` + +### Authorization Parameters +```php +// Available on all form components (Input, Select, Textarea, Checkbox, Button) +public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete' +public mixed $canResource = null; // Resource model instance to check against +public bool $autoDisable = true; // Automatically disable if no permission (default: true) +``` + +### Benefits +- **90% code reduction** for authorization-protected forms +- **Consistent security** across all form components +- **Automatic disabling** for unauthorized users +- **Smart behavior** (disables instantSave on checkboxes for unauthorized users) + +For complete documentation, see **[form-components.mdc](mdc:.cursor/rules/form-components.mdc)** + ## Form Handling Patterns ### Livewire Forms diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc new file mode 100644 index 000000000..005ede849 --- /dev/null +++ b/.cursor/rules/laravel-boost.mdc @@ -0,0 +1,405 @@ +--- +alwaysApply: true +--- + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.7 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 +- laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 +- laravel/socialite (SOCIALITE) - v5 +- livewire/livewire (LIVEWIRE) - v3 +- laravel/dusk (DUSK) - v8 +- laravel/pint (PINT) - v1 +- laravel/telescope (TELESCOPE) - v5 +- pestphp/pest (PEST) - v3 +- phpunit/phpunit (PHPUNIT) - v11 +- rector/rector (RECTOR) - v2 +- laravel-echo (ECHO) - v2 +- tailwindcss (TAILWINDCSS) - v4 +- vue (VUE) - v3 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure. +- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that. + +### Laravel 10 Structure +- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`. +- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure: + - Middleware registration happens in `app/Http/Kernel.php` + - Exception handling is in `app/Exceptions/Handler.php` + - Console commands and schedule register in `app/Console/Kernel.php` + - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php` + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc index 2be9f31e6..b615a5d3e 100644 --- a/.cursor/rules/project-overview.mdc +++ b/.cursor/rules/project-overview.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: High-level project mission, core concepts, and architectural overview +globs: README.md, CONTRIBUTING.md, CHANGELOG.md, *.md alwaysApply: false --- # Coolify Project Overview diff --git a/.cursor/rules/security-patterns.mdc b/.cursor/rules/security-patterns.mdc index 9cdbcaa0c..a7ab2ad69 100644 --- a/.cursor/rules/security-patterns.mdc +++ b/.cursor/rules/security-patterns.mdc @@ -1,7 +1,7 @@ --- -description: -globs: -alwaysApply: false +description: Security architecture, authentication, authorization patterns, and enhanced form component security +globs: app/Policies/*.php, app/View/Components/Forms/*.php, app/Http/Middleware/*.php, resources/views/**/*.blade.php +alwaysApply: true --- # Coolify Security Architecture & Patterns @@ -63,6 +63,323 @@ class User extends Authenticatable ## Authorization & Access Control +### Enhanced Form Component Authorization System + +Coolify now features a **centralized authorization system** built into all form components (`Input`, `Select`, `Textarea`, `Checkbox`, `Button`) that automatically handles permission-based UI control. + +#### Component Authorization Parameters +```php +// Available on all form components +public ?string $canGate = null; // Gate name (e.g., 'update', 'view', 'delete') +public mixed $canResource = null; // Resource to check against (model instance) +public bool $autoDisable = true; // Auto-disable if no permission (default: true) +``` + +#### Smart Authorization Logic +```php +// Automatic authorization handling in component constructor +if ($this->canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (! $hasPermission) { + $this->disabled = true; + // For Checkbox: also disables instantSave + } +} +``` + +#### Usage Examples + +**✅ Recommended Pattern (Single Line):** +```html + + + + + + + + + + + + + + + Save Configuration + +``` + +**❌ Old Pattern (Verbose, Deprecated):** +```html + +@can('update', $application) + + Save +@else + +@endcan +``` + +#### Advanced Usage with Custom Control + +**Custom Authorization Logic:** +```html + + +``` + +**Multiple Permission Checks:** +```html + + +``` + +#### Supported Gates and Resources + +**Common Gates:** +- `view` - Read access to resource +- `update` - Modify resource configuration +- `deploy` - Deploy/restart resource +- `delete` - Remove resource +- `createAnyResource` - Create new resources + +**Resource Types:** +- `Application` - Application instances +- `Service` - Docker Compose services +- `Server` - Server instances +- `Project` - Project containers +- `Environment` - Environment contexts +- `Database` - Database instances + +#### Benefits + +**🔥 Massive Code Reduction:** +- **90% less code** for authorization-protected forms +- **Single line** instead of 6-12 lines per form element +- **No more @can/@else blocks** cluttering templates + +**🛡️ Consistent Security:** +- **Unified authorization logic** across all form components +- **Automatic disabling** for unauthorized users +- **Smart behavior** (like disabling instantSave on checkboxes) + +**🎨 Better UX:** +- **Consistent disabled styling** across all components +- **Proper visual feedback** for restricted access +- **Clean, professional interface** + +#### Implementation Details + +**Component Enhancement:** +```php +// Enhanced in all form components +use Illuminate\Support\Facades\Gate; + +public function __construct( + // ... existing parameters + public ?string $canGate = null, + public mixed $canResource = null, + public bool $autoDisable = true, +) { + // Handle authorization-based disabling + if ($this->canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (! $hasPermission) { + $this->disabled = true; + // For Checkbox: $this->instantSave = false; + } + } +} +``` + +**Backward Compatibility:** +- All existing form components continue to work unchanged +- New authorization parameters are optional +- Legacy @can/@else patterns still function but are discouraged + +### Custom Component Authorization Patterns + +When dealing with **custom Alpine.js components** or complex UI elements that don't use the standard `x-forms.*` components, manual authorization protection is required since the automatic `canGate` system only applies to enhanced form components. + +#### Common Custom Components Requiring Manual Protection + +**⚠️ Custom Components That Need Manual Authorization:** +- Custom dropdowns/selects with Alpine.js +- Complex form widgets with JavaScript interactions +- Multi-step wizards or dynamic forms +- Third-party component integrations +- Custom date/time pickers +- File upload components with drag-and-drop + +#### Manual Authorization Pattern + +**✅ Proper Manual Authorization:** +```html + +
+
+ + +
+ @can('update', $resource) + +
+ +
+ + +
+
+ @else + +
+ + + + +
+ @endcan +
+``` + +#### Implementation Checklist + +When implementing authorization for custom components: + +**🔍 1. Identify Custom Components:** +- Look for Alpine.js `x-data` declarations +- Find components not using `x-forms.*` prefix +- Check for JavaScript-heavy interactions +- Review complex form widgets + +**🛡️ 2. Wrap with Authorization:** +- Use `@can('gate', $resource)` / `@else` / `@endcan` structure +- Provide full functionality in the `@can` block +- Create disabled/readonly version in the `@else` block + +**🎨 3. Design Disabled State:** +- Apply `readonly disabled` attributes to inputs +- Add `opacity-50 cursor-not-allowed` classes for visual feedback +- Remove interactive JavaScript behaviors +- Show current value or appropriate placeholder + +**🔒 4. Backend Protection:** +- Ensure corresponding Livewire methods check authorization +- Add `$this->authorize('gate', $resource)` in relevant methods +- Validate permissions before processing any changes + +#### Real-World Examples + +**Custom Date Range Picker:** +```html +@can('update', $application) +
+ +
+@else +
+ + +
+@endcan +``` + +**Multi-Select Component:** +```html +@can('update', $server) +
+ +
+@else +
+ @foreach($selectedValues as $value) +
+ {{ $value }} +
+ @endforeach +
+@endcan +``` + +**File Upload Widget:** +```html +@can('update', $application) +
+ +
+@else +
+

File upload restricted

+ @if($currentFile) +

Current: {{ $currentFile }}

+ @endif +
+@endcan +``` + +#### Key Principles + +**🎯 Consistency:** +- Maintain similar visual styling between enabled/disabled states +- Use consistent disabled patterns across the application +- Apply the same opacity and cursor styling + +**🔐 Security First:** +- Always implement backend authorization checks +- Never rely solely on frontend hiding/disabling +- Validate permissions on every server action + +**💡 User Experience:** +- Show current values in disabled state when appropriate +- Provide clear visual feedback about restricted access +- Maintain layout stability between states + +**🚀 Performance:** +- Minimize Alpine.js initialization for disabled components +- Avoid loading unnecessary JavaScript for unauthorized users +- Use simple HTML structures for read-only states + ### Team-Based Multi-Tenancy - **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure (8.9KB, 308 lines) - **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Secure team collaboration diff --git a/.cursor/rules/self_improve.mdc b/.cursor/rules/self_improve.mdc index 40b31b6ea..2bebaec75 100644 --- a/.cursor/rules/self_improve.mdc +++ b/.cursor/rules/self_improve.mdc @@ -31,19 +31,6 @@ alwaysApply: true - Related rules have been updated - Implementation details have changed -- **Example Pattern Recognition:** - ```typescript - // If you see repeated patterns like: - const data = await prisma.user.findMany({ - select: { id: true, email: true }, - where: { status: 'ACTIVE' } - }); - - // Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc): - // - Standard select fields - // - Common where conditions - // - Performance optimization patterns - ``` - **Rule Quality Checks:** - Rules should be actionable and specific diff --git a/.cursor/rules/technology-stack.mdc b/.cursor/rules/technology-stack.mdc index 81a2e3bb3..2119a2ff1 100644 --- a/.cursor/rules/technology-stack.mdc +++ b/.cursor/rules/technology-stack.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: Complete technology stack, dependencies, and infrastructure components +globs: composer.json, package.json, docker-compose*.yml, config/*.php alwaysApply: false --- # Coolify Technology Stack diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc index c3eabe09f..a0e64dbae 100644 --- a/.cursor/rules/testing-patterns.mdc +++ b/.cursor/rules/testing-patterns.mdc @@ -1,6 +1,6 @@ --- -description: -globs: +description: Testing strategies with Pest PHP, Laravel Dusk, and quality assurance patterns +globs: tests/**/*.php, database/factories/*.php alwaysApply: false --- # Coolify Testing Architecture & Patterns @@ -9,6 +9,8 @@ alwaysApply: false Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions. +!Important: Always run tests inside `coolify` container. + ## Testing Framework Stack ### Core Testing Tools diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..a2c92df59 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,79 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + if: false + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@beta + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) + # model: "claude-opus-4-1-20250805" + + # Direct prompt for automated review (no @claude mention needed) + direct_prompt: | + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Be constructive and helpful in your feedback. + + # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR + # use_sticky_comment: true + + # Optional: Customize review based on file types + # direct_prompt: | + # Review this PR focusing on: + # - For TypeScript files: Type safety and proper interface usage + # - For API endpoints: Security, input validation, and error handling + # - For React components: Performance, accessibility, and best practices + # - For tests: Coverage, edge cases, and test quality + + # Optional: Different prompts for different authors + # direct_prompt: | + # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && + # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || + # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} + + # Optional: Add specific tools for running tests or linting + # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" + + # Optional: Skip review for certain conditions + # if: | + # !contains(github.event.pull_request.title, '[skip-review]') && + # !contains(github.event.pull_request.title, '[WIP]') + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..bc773072b --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,64 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@beta + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) + # model: "claude-opus-4-1-20250805" + + # Optional: Customize the trigger phrase (default: @claude) + # trigger_phrase: "/claude" + + # Optional: Trigger when specific user is assigned to an issue + # assignee_trigger: "claude-bot" + + # Optional: Allow Claude to run specific commands + # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" + + # Optional: Add custom instructions for Claude to customize its behavior for your project + # custom_instructions: | + # Follow our coding standards + # Ensure all new code has tests + # Use TypeScript for new files + + # Optional: Custom environment variables for Claude + # claude_env: | + # NODE_ENV: test + diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..8c6715a15 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.phpactor.json b/.phpactor.json new file mode 100644 index 000000000..4d42bbbc5 --- /dev/null +++ b/.phpactor.json @@ -0,0 +1,4 @@ +{ + "$schema": "/phpactor.schema.json", + "language_server_phpstan.enabled": true +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf445f74..4360a7c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,25 +10,508 @@ All notable changes to this project will be documented in this file. ### ⚙️ Miscellaneous Tasks +- *(docker)* Add a blank line for improved readability in Dockerfile + +## [4.0.0-beta.428] - 2025-09-15 + +### 🚀 Features + +- *(deployment)* Enhance deployment status reporting with detailed information on active deployments and team members + +### 🐛 Bug Fixes + +- *(application)* Improve watch paths handling by trimming and filtering empty paths to prevent unnecessary triggers + +### 🚜 Refactor + +- *(deployment)* Streamline environment variable handling for dockercompose and improve sorting of runtime variables +- *(remoteProcess)* Remove command log comments for file transfers to simplify code +- *(remoteProcess)* Remove file transfer handling from remote_process and instant_remote_process functions to simplify code +- *(deployment)* Update environment file paths in docker compose commands to use working directory for improved consistency + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(constants)* Update realtime_version from 1.0.10 to 1.0.11 +- *(versions)* Increment coolify version to 4.0.0-beta.428 and update realtime_version to 1.0.10 + +## [4.0.0-beta.427] - 2025-09-15 + +### 🚀 Features + +- *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic +- *(ui)* Display current version in settings dropdown and update UI accordingly +- *(settings)* Add option to restrict PR deployments to repository members and contributors +- *(command)* Implement SSH command retry logic with exponential backoff and logging for better error handling +- *(ssh)* Add Sentry tracking for SSH retry events to enhance error monitoring +- *(exceptions)* Introduce NonReportableException to handle known errors and update Handler for selective reporting +- *(sudo-helper)* Add helper functions for command parsing and ownership management with sudo +- *(dev-command)* Dispatch CheckHelperImageJob during instance initialization to enhance setup process +- *(ssh-multiplexing)* Enhance multiplexed connection management with health checks and metadata caching +- *(ssh-multiplexing)* Add connection age metadata handling to improve multiplexed connection management +- *(database-backup)* Enhance error handling and output management in DatabaseBackupJob +- *(application)* Display parsing version in development mode and clean up domain conflict modal markup +- *(deployment)* Add SERVICE_NAME variables for service discovery +- *(storages)* Add method to retrieve the first storage ID for improved stability in storage display +- *(environment)* Add 'is_literal' attribute to environment variable for enhanced configuration options +- *(pre-commit)* Automate generation of service templates and OpenAPI documentation during pre-commit hook +- *(execute-container)* Enhance container command form with auto-connect feature for single container scenarios +- *(environment)* Introduce 'is_buildtime_only' attribute to environment variables for improved build-time configuration +- *(templates)* Add n8n service with PostgreSQL and worker support for enhanced workflow automation +- *(user-management)* Implement user deletion command with phased resource and subscription cancellation, including dry run option +- *(sentinel)* Add support for custom Docker images in StartSentinel and related methods +- *(sentinel)* Add slide-over for viewing Sentinel logs and custom Docker image input for development +- *(executions)* Add 'Load All' button to view all logs and implement loadAllLogs method for complete log retrieval +- *(auth)* Enhance user login flow to handle team invitations, attaching users to invited teams upon first login and maintaining personal team logic for regular logins +- *(laravel-boost)* Add Laravel Boost guidelines and MCP server configuration to enhance development experience + +### 🐛 Bug Fixes + +- *(ui)* Transactional email settings link on members page (#6491) +- *(api)* Add custom labels generation for applications with readonly container label setting enabled +- *(ui)* Add cursor pointer to upgrade button for better user interaction +- *(templates)* Update SECRET_KEY environment variable in getoutline.yaml to use SERVICE_HEX_32_OUTLINE +- *(command)* Enhance database deletion command to support multiple database types +- *(command)* Enhance cleanup process for stuck application previews by adding force delete for trashed records +- *(user)* Ensure email attributes are stored in lowercase for consistency and prevent case-related issues +- *(webhook)* Replace delete with forceDelete for application previews to ensure immediate removal +- *(ssh)* Introduce SshRetryHandler and SshRetryable trait for enhanced SSH command retry logic with exponential backoff and error handling +- Appwrite template - 500 errors, missing env vars etc. +- *(LocalFileVolume)* Add missing directory creation command for workdir in saveStorageOnServer method +- *(ScheduledTaskJob)* Replace generic Exception with NonReportableException for better error handling +- *(web-routes)* Enhance backup response messages to clarify local and S3 availability +- *(proxy)* Replace CheckConfiguration with GetProxyConfiguration and SaveConfiguration with SaveProxyConfiguration for improved clarity and consistency in proxy management +- *(private-key)* Implement transaction handling and error verification for private key storage operations +- *(deployment)* Add COOLIFY_* environment variables to Nixpacks build context for enhanced deployment configuration +- *(application)* Add functionality to stop and remove Docker containers on server +- *(templates)* Update 'compose' configuration for Appwrite service to enhance compatibility and streamline deployment +- *(security)* Update contact email for reporting vulnerabilities to enhance privacy +- *(feedback)* Update feedback email address to improve communication with users +- *(security)* Update contact email for vulnerability reports to improve security communication +- *(navbar)* Restrict subscription link visibility to admin users in cloud environment +- *(docker)* Enhance container status aggregation for multi-container applications, including exclusion handling based on docker-compose configuration + +### 🚜 Refactor + +- *(jobs)* Pull github changelogs from cdn instead of github +- *(command)* Streamline database deletion process to handle multiple database types and improve user experience +- *(command)* Improve database collection logic for deletion command by using unique identifiers and enhancing user experience +- *(command)* Remove InitChangelog command as it is no longer needed +- *(command)* Streamline Init command by removing unnecessary options and enhancing error handling for various operations +- *(webhook)* Replace direct forceDelete calls with DeleteResourceJob dispatch for application previews +- *(command)* Replace forceDelete calls with DeleteResourceJob dispatch for all stuck resources in cleanup process +- *(command)* Simplify SSH command retry logic by removing unnecessary logging and improving delay calculation +- *(ssh)* Enhance error handling in SSH command execution and improve connection validation logging +- *(backlog)* Remove outdated guidelines and project manager agent files to streamline task management documentation +- *(error-handling)* Remove ray debugging statements from CheckUpdates and shared helper functions to clean up error reporting +- *(file-transfer)* Replace base64 encoding with direct file transfer method across multiple database actions for improved clarity and efficiency +- *(remoteProcess)* Remove debugging statement from transfer_file_to_server function to clean up code +- *(dns-validation)* Rename DNS validation functions for consistency and clarity, and remove unused code +- *(file-transfer)* Replace base64 encoding with direct file transfer method in various components for improved clarity and efficiency +- *(private-key)* Remove debugging statement from storeInFileSystem method for cleaner code +- *(github-webhook)* Restructure application processing by grouping applications by server for improved deployment handling +- *(deployment)* Enhance queuing logic to support concurrent deployments by including pull request ID in checks +- *(remoteProcess)* Remove debugging statement from transfer_file_to_container function for cleaner code +- *(deployment)* Streamline next deployment queuing logic by repositioning queue_next_deployment call +- *(deployment)* Add validation for pull request existence in deployment process to enhance error handling +- *(database)* Remove volume_configuration_dir and streamline configuration directory usage in MongoDB and PostgreSQL handlers +- *(application-source)* Improve layout and accessibility of Git repository links in the application source view +- *(models)* Remove 'is_readonly' attribute from multiple database models for consistency +- *(webhook)* Remove Webhook model and related logic; add migrations to drop webhooks and kubernetes tables +- *(clone)* Consolidate application cloning logic into a dedicated function for improved maintainability and readability +- *(clone)* Integrate preview cloning logic directly into application cloning function for improved clarity and maintainability +- *(application)* Enhance environment variable retrieval in configuration change check for improved accuracy +- *(clone)* Enhance application cloning by separating production and preview environment variable handling +- *(deployment)* Add environment variable copying logic to Docker build commands for pull requests +- *(environment)* Standardize service name formatting by replacing '-' and '.' with '_' in environment variable keys +- *(deployment)* Update environment file handling in Docker commands to use '/artifacts/' path and streamline variable management +- *(openapi)* Remove 'is_build_time' attribute from environment variable definitions to streamline configuration +- *(environment)* Remove 'is_build_time' attribute from environment variable handling across the application to simplify configuration +- *(environment)* Streamline environment variable handling by replacing sorting methods with direct property access and enhancing query ordering for improved performance +- *(stripe-jobs)* Comment out internal notification calls and add subscription status verification before sending failure notifications + +### 📚 Documentation + +- Update changelog +- *(testing-patterns)* Add important note to always run tests inside the `coolify` container for clarity + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.427 and nightly version to 4.0.0-beta.428 +- Use main value then fallback to service_ values +- Remove webhooks table cleanup +- *(cleanup)* Remove deprecated ServerCheck and related job classes to streamline codebase +- *(versions)* Update sentinel version from 0.0.15 to 0.0.16 in versions.json files + +## [4.0.0-beta.426] - 2025-08-28 + +### 🚜 Refactor + +- *(policy)* Simplify ServiceDatabasePolicy methods to always return true and add manageBackups method + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.426 and nightly version to 4.0.0-beta.427 + +## [4.0.0-beta.425] - 2025-08-28 + +### 🚀 Features + +- *(domains)* Implement domain conflict detection and user confirmation modal across application components +- *(domains)* Add force_domain_override option and enhance domain conflict detection responses + +### 🐛 Bug Fixes + +- *(previews)* Simplify FQDN generation logic by removing unnecessary empty check +- *(templates)* Update Matrix service compose configuration for improved compatibility and clarity + +### 🚜 Refactor + +- *(urls)* Replace generateFqdn with generateUrl for consistent URL generation across applications +- *(domains)* Rename check_domain_usage to checkDomainUsage and update references across the application +- *(auth)* Simplify access control logic in CanAccessTerminal and ServerPolicy by allowing all users to perform actions + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.425 and nightly version to 4.0.0-beta.426 + +## [4.0.0-beta.424] - 2025-08-27 + +### 🐛 Bug Fixes + +- *(parsers)* Do not modify service names, only for getting fqdns and related envs +- *(compose)* Temporary allow to edit volumes in apps (compose based) and services + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.424 and nightly version to 4.0.0-beta.425 + +## [4.0.0-beta.423] - 2025-08-27 + +### 🚜 Refactor + +- *(parsers)* Remove unnecessary hyphen-to-underscore replacement for service names in serviceParser function + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.423 and nightly version to 4.0.0-beta.424 + +## [4.0.0-beta.422] - 2025-08-27 + +### 🐛 Bug Fixes + +- *(parsers)* Replace hyphens with underscores in service names for consistency. this allows to properly parse custom domains in docker compose based applications +- *(parsers)* Implement parseDockerVolumeString function to handle various Docker volume formats and modes, including environment variables and Windows paths. Add unit tests for comprehensive coverage. +- *(git)* Submodule update command uses an unsupported option (#6454) +- *(service)* Swap URL for FQDN on matrix template (#6466) +- *(parsers)* Enhance volume string handling by preserving mode in application and service parsers. Update related unit tests for validation. +- *(docker)* Update parser version in FQDN generation for service-specific URLs + +### 🚜 Refactor + +- *(git)* Improve submodule cloning + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update version +- Update development node version + +## [4.0.0-beta.421] - 2025-08-26 + +### 🚀 Features + +- *(policies)* Add EnvironmentVariablePolicy for managing environment variables ( it was missing ) + +### 🐛 Bug Fixes + +- *(backups)* Rollback helper update for now + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(core)* Update version +- *(versions)* Update coolify version to 4.0.0-beta.421 and nightly version to 4.0.0-beta.422 + +## [4.0.0-beta.420.9] - 2025-08-26 + +### 🐛 Bug Fixes + +- *(backups)* S3 backup upload is failing + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(core)* Update version + +## [4.0.0-beta.420.8] - 2025-08-26 + +### 🚜 Refactor + +- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility + +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.420.7] - 2025-08-26 + +### 🚀 Features + +- *(service)* Add TriliumNext service (#5970) +- *(service)* Add Matrix service (#6029) +- *(service)* Add GitHub Action runner service (#6209) +- *(terminal)* Dispatch focus event for terminal after connection and enhance focus handling in JavaScript +- *(lang)* Add Polish language & improve forgot_password translation (#6306) +- *(service)* Update Authentik template (#6264) +- *(service)* Add sequin template (#6105) +- *(service)* Add pi-hole template (#6020) +- *(services)* Add Chroma service (#6201) +- *(service)* Add OpenPanel template (#5310) +- *(service)* Add librechat template (#5654) +- *(service)* Add Homebox service (#6116) +- *(service)* Add pterodactyl & wings services (#5537) +- *(service)* Add Bluesky PDS template (#6302) +- *(input)* Add autofocus attribute to input component for improved accessibility +- *(core)* Finally fqdn is fqdn and url is url. haha +- *(user)* Add changelog read tracking and unread count method +- *(templates)* Add new service templates and update existing compose files for various applications +- *(changelog)* Implement automated changelog fetching from GitHub and enhance changelog read tracking +- *(drizzle-gateway)* Add new drizzle-gateway service with configuration and logo +- *(drizzle-gateway)* Enhance service configuration by adding Master Password field and updating compose file path +- *(templates)* Add new service templates for Homebox, LibreChat, Pterodactyl, and Wings with corresponding configurations and logos +- *(templates)* Add Bluesky PDS service template and update compose file with new environment variable +- *(readme)* Add CubePath as a big sponsor and include new small sponsors with logos +- *(api)* Add create_environment endpoint to ProjectController for environment creation in projects +- *(api)* Add endpoints for managing environments in projects, including listing, creating, and deleting environments +- *(backup)* Add disable local backup option and related logic for S3 uploads +- *(dev patches)* Add functionality to send test email with patch data in development mode +- *(templates)* Added category per service +- *(email)* Implement email change request and verification process +- Generate category for services +- *(service)* Add elasticsearch template (#6300) +- *(sanitization)* Integrate DOMPurify for HTML sanitization across components +- *(cleanup)* Add command for sanitizing name fields across models +- *(sanitization)* Enhance HTML sanitization with improved DOMPurify configuration +- *(validation)* Centralize validation patterns for names and descriptions +- *(git-settings)* Add support for shallow cloning in application settings +- *(auth)* Implement authorization checks for server updates across multiple components +- *(auth)* Implement authorization for PrivateKey management +- *(auth)* Implement authorization for Docker and server management +- *(validation)* Add custom validation rules for Git repository URLs and branches +- *(security)* Add authorization checks for package updates in Livewire components +- *(auth)* Implement authorization checks for application management +- *(auth)* Enhance API error handling for authorization exceptions +- *(auth)* Add comprehensive authorization checks for all kind of resource creations +- *(auth)* Implement authorization checks for database management +- *(auth)* Refine authorization checks for S3 storage and service management +- *(auth)* Implement comprehensive authorization checks across API controllers +- *(auth)* Introduce resource creation authorization middleware and policies for enhanced access control +- *(auth)* Add middleware for resource creation authorization +- *(auth)* Enhance authorization checks in Livewire components for resource management +- *(validation)* Add ValidIpOrCidr rule for validating IP addresses and CIDR notations; update API access settings UI and add comprehensive tests +- *(docs)* Update architecture and development guidelines; enhance form components with built-in authorization system and improve routing documentation +- *(docs)* Expand authorization documentation for custom Alpine.js components; include manual protection patterns and implementation guidelines +- *(sentinel)* Implement SentinelRestarted event and update Livewire components to handle server restart notifications +- *(api)* Enhance IP access control in middleware and settings; support CIDR notation and special case for 0.0.0.0 to allow all IPs +- *(acl)* Change views/backend code to able to use proper ACL's later on. Currently it is not enabled. +- *(docs)* Add Backlog.md guidelines and project manager backlog agent; enhance CLAUDE.md with new links for task management +- *(docs)* Add tasks for implementing Docker build caching and optimizing staging builds; include detailed acceptance criteria and implementation plans +- *(docker)* Implement Docker cleanup processing in ScheduledJobManager; refactor server task scheduling to streamline cleanup job dispatching +- *(docs)* Expand Backlog.md guidelines with comprehensive usage instructions, CLI commands, and best practices for task management to enhance project organization and collaboration + +### 🐛 Bug Fixes + +- *(service)* Triliumnext platform and link +- *(application)* Update service environment variables when generating domain for Docker Compose +- *(application)* Add option to suppress toast notifications when loading compose file +- *(git)* Tracking issue due to case sensitivity +- *(git)* Tracking issue due to case sensitivity +- *(git)* Tracking issue due to case sensitivity +- *(ui)* Delete button width on small screens (#6308) +- *(service)* Matrix entrypoint +- *(ui)* Add flex-wrap to prevent overflow on small screens (#6307) +- *(docker)* Volumes get delete when stopping a service if `Delete Unused Volumes` is activated (#6317) +- *(docker)* Cleanup always running on deletion +- *(proxy)* Remove hardcoded port 80/443 checks (#6275) +- *(service)* Update healthcheck of penpot backend container (#6272) +- *(api)* Duplicated logs in application endpoint (#6292) +- *(service)* Documenso signees always pending (#6334) +- *(api)* Update service upsert to retain name and description values if not set +- *(database)* Custom postgres configs with SSL (#6352) +- *(policy)* Update delete method to check for admin status in S3StoragePolicy +- *(container)* Sort containers alphabetically by name in ExecuteContainerCommand and update filtering in Terminal Index +- *(application)* Streamline environment variable updates for Docker Compose services and enhance FQDN generation logic +- *(constants)* Update 'Change Log' to 'Changelog' in settings dropdown +- *(constants)* Update coolify version to 4.0.0-beta.420.7 +- *(parsers)* Clarify comments and update variable checks for FQDN and URL handling +- *(terminal)* Update text color for terminal availability message and improve readability +- *(drizzle-gateway)* Remove healthcheck from drizzle-gateway compose file and update service template +- *(templates)* Should generate old SERVICE_FQDN service templates as well +- *(constants)* Update official service template URL to point to the v4.x branch for accuracy +- *(git)* Use exact refspec in ls-remote to avoid matching similarly named branches (e.g., changeset-release/main). Use refs/heads/ or provider-specific PR refs. +- *(ApplicationPreview)* Change null check to empty check for fqdn in generate_preview_fqdn method +- *(email notifications)* Enhance EmailChannel to validate team membership for recipients and handle errors gracefully +- *(service api)* Separate create and update service functionalities +- *(templates)* Added a category tag for the docs service filter +- *(application)* Clear Docker Compose specific data when switching away from dockercompose +- *(database)* Conditionally set started_at only if the database is running +- *(ui)* Handle null values in postgres metrics (#6388) +- Disable env sorting by default +- *(proxy)* Filter host network from default proxy (#6383) +- *(modal)* Enhance confirmation text handling +- *(notification)* Update unread count display and improve HTML rendering +- *(select)* Remove unnecessary sanitization for logo rendering +- *(tags)* Update tag display to limit name length and adjust styling +- *(init)* Improve error handling for deployment and template pulling processes +- *(settings-dropdown)* Adjust unread count badge size and display logic for better consistency +- *(sanitization)* Enhance DOMPurify hook to remove Alpine.js directives for improved XSS protection +- *(servercheck)* Properly check server statuses with and without Sentinel +- *(errors)* Update error pages to provide navigation options +- *(github-deploy-key)* Update background color for selected private keys in deployment key selection UI +- *(auth)* Enhance authorization checks in application management + +### 💼 Other + +- *(settings-dropdown)* Add icons to buttons for improved UI in settings dropdown +- *(ui)* Introduce task for simplifying resource operations UI by replacing boxes with dropdown selections to enhance user experience and streamline interactions + +### 🚜 Refactor + +- *(jobs)* Remove logging for ScheduledJobManager and ServerResourceManager start and completion +- *(services)* Update validation rules to be optional +- *(service)* Improve langfuse +- *(service)* Improve openpanel template +- *(service)* Improve librechat +- *(public-git-repository)* Enhance form structure and add autofocus to repository URL input +- *(public-git-repository)* Remove commented-out code for cleaner template +- *(templates)* Update service template file handling to use dynamic file name from constants +- *(parsers)* Streamline domain handling in applicationParser and improve DNS validation logic +- *(templates)* Replace SERVICE_FQDN variables with SERVICE_URL in compose files for consistency +- *(links)* Replace inline SVGs with reusable external link component for consistency and improved maintainability +- *(previews)* Improve layout and add deployment/application logs links for previews +- *(docker compose)* Remove deprecated newParser function and associated test file to streamline codebase +- *(shared helpers)* Remove unused parseServiceVolumes function to clean up codebase +- *(parsers)* Update volume parsing logic to use beforeLast and afterLast for improved accuracy +- *(validation)* Implement centralized validation patterns across components +- *(jobs)* Rename job classes to indicate deprecation status +- Update check frequency logic for cloud and self-hosted environments; streamline server task scheduling and timezone handling +- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility + +### 📚 Documentation + +- *(claude)* Clarify that artisan commands should only be run inside the "coolify" container during development +- Add AGENTS.md for project guidance and development instructions +- Update changelog +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(service)* Improve matrix service +- *(service)* Format runner service +- *(service)* Improve sequin +- *(service)* Add `NOT_SECURED` env to Postiz (#6243) +- *(service)* Improve evolution-api environment variables (#6283) +- *(service)* Update Langfuse template to v3 (#6301) +- *(core)* Remove unused argument +- *(deletion)* Rename isDeleteOperation to deleteConnectedNetworks +- *(docker)* Remove unused arguments on StopService +- *(service)* Homebox formatting +- Clarify usage of custom redis configuration (#6321) +- *(changelogs)* Add .gitignore for changelogs directory and remove outdated changelog files for May, June, and July 2025 +- *(service)* Change affine images (#6366) +- Elasticsearch URL, fromatting and add category +- Update service-templates json files +- *(docs)* Remove AGENTS.md file; enhance CLAUDE.md with detailed form authorization patterns and service configuration examples +- *(cleanup)* Remove unused GitLab view files for change, new, and show pages +- *(workflows)* Add backlog directory to build triggers for production and staging workflows +- *(config)* Disable auto_commit in backlog configuration to prevent automatic commits +- *(versions)* Update coolify version to 4.0.0-beta.420.8 and nightly version to 4.0.0-beta.420.9 in versions.json and constants.php +- *(docker)* Update soketi image version to 1.0.10 in production and Windows configurations + +### ◀️ Revert + +- *(parser)* Enhance FQDN generation logic for services and applications + +## [4.0.0-beta.420.6] - 2025-07-18 + +### 🚀 Features + +- *(service)* Enable password protection for the Wireguard Ul +- *(queues)* Improve Horizon config to reduce CPU and RAM usage (#6212) +- *(service)* Add Gowa service (#6164) +- *(container)* Add updatedSelectedContainer method to connect to non-default containers and update wire:model for improved reactivity +- *(application)* Implement environment variable updates for Docker Compose applications, including creation, updating, and deletion of SERVICE_FQDN and SERVICE_URL variables + +### 🐛 Bug Fixes + +- *(installer)* Public IPv4 link does not work +- *(composer)* Version constraint of prompts +- *(service)* Budibase secret keys (#6205) +- *(service)* Wg-easy host should be just the FQDN +- *(ui)* Search box overlaps the sidebar navigation (#6176) +- *(webhooks)* Exclude webhook routes from CSRF protection (#6200) +- *(services)* Update environment variable naming convention to use underscores instead of dashes for SERVICE_FQDN and SERVICE_URL + +### 🚜 Refactor + +- *(service)* Improve gowa +- *(previews)* Streamline preview domain generation logic in ApplicationDeploymentJob for improved clarity and maintainability +- *(services)* Simplify environment variable updates by using updateOrCreate and add cleanup for removed FQDNs + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(service)* Update Nitropage template (#6181) +- *(versions)* Update all version - *(bump)* Update composer deps - *(version)* Bump Coolify version to 4.0.0-beta.420.6 -## [4.0.0-beta.420.5] - 2025-07-08 +## [4.0.0-beta.420.4] - 2025-07-08 ### 🚀 Features - *(scheduling)* Add command to manually run scheduled database backups and tasks with options for chunking, delays, and dry runs - -### 🐛 Bug Fixes - -- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6 -- *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy - -### 🚜 Refactor - -- *(postgresql)* Improve layout and spacing in SSL and Proxy configuration sections for better UI consistency - -## [4.0.0-beta.420.4] - 2025-07-08 +- *(scheduling)* Add frequency filter option for manual execution of scheduled jobs +- *(logging)* Implement scheduled logs command and enhance backup/task scheduling with cron checks +- *(logging)* Add frequency filters for scheduled logs command to support hourly, daily, weekly, and monthly job views +- *(scheduling)* Introduce ScheduledJobManager and ServerResourceManager for enhanced job scheduling and resource management +- *(previews)* Implement soft delete and cleanup for ApplicationPreview, enhancing resource management in DeleteResourceJob ### 🐛 Bug Fixes @@ -41,11 +524,29 @@ All notable changes to this project will be documented in this file. - *(deployment)* Refactor domain parsing and environment variable generation using Spatie URL library - *(deployment)* Update COOLIFY_URL and COOLIFY_FQDN generation to use Spatie URL library for improved accuracy - *(scheduling)* Change redis cleanup command frequency from hourly to weekly for better resource management +- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6 +- *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy +- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.6 and 4.0.0-beta.420.7 +- *(scheduling)* Remove unnecessary padding from scheduled task form layout for improved UI consistency +- *(horizon)* Update queue configuration to use environment variable for dynamic queue management +- *(horizon)* Add silenced jobs +- *(application)* Sanitize service names for HTML form binding and ensure original names are stored in docker compose domains +- *(previews)* Adjust padding for rate limit message in application previews +- *(previews)* Order application previews by pull request ID in descending order +- *(previews)* Add unique wire keys for preview containers and services based on pull request ID +- *(previews)* Enhance domain generation logic for application previews, ensuring unique domains are created when none are set +- *(previews)* Refine preview domain generation for Docker Compose applications, ensuring correct method usage based on build pack type +- *(ui)* Typo on proxy request handler tooltip (#6192) +- *(backups)* Large database backups are not working (#6217) +- *(backups)* Error message if there is no exception ### 🚜 Refactor - *(previews)* Streamline preview URL generation by utilizing application method - *(application)* Adjust layout and spacing in general application view for improved UI +- *(postgresql)* Improve layout and spacing in SSL and Proxy configuration sections for better UI consistency +- *(scheduling)* Replace deprecated job checks with ScheduledJobManager and ServerResourceManager for improved scheduling efficiency +- *(previews)* Move preview domain generation logic to ApplicationPreview model for better encapsulation and consistency across webhook handlers ### 📚 Documentation @@ -7909,4 +8410,6 @@ All notable changes to this project will be documented in this file. - Secrets join - ENV variables set differently +## [1.0.0] - 2021-03-24 + diff --git a/CLAUDE.md b/CLAUDE.md index a3bb31cee..22e762182 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ Coolify is an open-source, self-hostable platform for deploying applications and - `npm run build` - Build frontend assets for production ### Backend Development +Only run artisan commands inside "coolify" container when in development. - `php artisan serve` - Start Laravel development server - `php artisan migrate` - Run database migrations - `php artisan queue:work` - Start queue worker for background jobs @@ -28,26 +29,27 @@ Coolify is an open-source, self-hostable platform for deploying applications and ### Technology Stack - **Backend**: Laravel 12 (PHP 8.4) -- **Frontend**: Livewire + Alpine.js + Tailwind CSS -- **Database**: PostgreSQL 15 -- **Cache/Queue**: Redis 7 +- **Frontend**: Livewire 3.5+ with Alpine.js and Tailwind CSS 4.1+ +- **Database**: PostgreSQL 15 (primary), Redis 7 (cache/queues) - **Real-time**: Soketi (WebSocket server) - **Containerization**: Docker & Docker Compose +- **Queue Management**: Laravel Horizon ### Key Components #### Core Models -- `Application` - Deployed applications with Git integration -- `Server` - Remote servers managed by Coolify -- `Service` - Docker Compose services +- `Application` - Deployed applications with Git integration (74KB, highly complex) +- `Server` - Remote servers managed by Coolify (46KB, complex) +- `Service` - Docker Compose services (58KB, complex) - `Database` - Standalone database instances (PostgreSQL, MySQL, MongoDB, Redis, etc.) - `Team` - Multi-tenancy support - `Project` - Grouping of environments and resources +- `Environment` - Environment isolation (staging, production, etc.) #### Job System - Uses Laravel Horizon for queue management - Key jobs: `ApplicationDeploymentJob`, `ServerCheckJob`, `DatabaseBackupJob` -- `ScheduledJobManager` and `ServerResourceManager` handle job scheduling +- `ServerManagerJob` and `ServerConnectionCheckJob` handle job scheduling #### Deployment Flow 1. Git webhook triggers deployment @@ -66,22 +68,591 @@ Coolify is an open-source, self-hostable platform for deploying applications and - `app/Jobs/` - Background queue jobs - `app/Livewire/` - Frontend components (full-stack with Livewire) - `app/Models/` - Eloquent models +- `app/Rules/` - Custom validation rules +- `app/Http/Middleware/` - HTTP middleware - `bootstrap/helpers/` - Helper functions for various domains - `database/migrations/` - Database schema evolution +- `routes/` - Application routing (web.php, api.php, webhooks.php, channels.php) +- `resources/views/livewire/` - Livewire component views +- `tests/` - Pest tests (Feature and Unit) ## Development Guidelines -### Code Organization -- Use Actions pattern for complex business logic -- Livewire components handle UI and user interactions -- Jobs handle asynchronous operations -- Traits provide shared functionality (e.g., `ExecuteRemoteCommand`) +### Frontend Philosophy +Coolify uses a **server-side first** approach with minimal JavaScript: +- **Livewire** for server-side rendering with reactive components +- **Alpine.js** for lightweight client-side interactions +- **Tailwind CSS** for utility-first styling with dark mode support +- **Enhanced Form Components** with built-in authorization system +- Real-time updates via WebSocket without page refreshes + +### Form Authorization Pattern +**IMPORTANT**: When creating or editing forms, ALWAYS include authorization: + +#### For Form Components (Input, Select, Textarea, Checkbox, Button): +Use `canGate` and `canResource` attributes for automatic authorization: +```html + +... + +Save +``` + +#### For Modal Components: +Wrap with `@can` directives: +```html +@can('update', $resource) + ... + ... +@endcan +``` + +#### In Livewire Components: +Always add the `AuthorizesRequests` trait and check permissions: +```php +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; + +class MyComponent extends Component +{ + use AuthorizesRequests; + + public function mount() + { + $this->authorize('view', $this->resource); + } + + public function update() + { + $this->authorize('update', $this->resource); + // ... update logic + } +} +``` + +### Livewire Component Structure +- Components located in `app/Livewire/` +- Views in `resources/views/livewire/` +- State management handled on the server +- Use wire:model for two-way data binding +- Dispatch events for component communication + +### Code Organization Patterns +- **Actions Pattern**: Use Actions for complex business logic (`app/Actions/`) +- **Livewire Components**: Handle UI and user interactions +- **Jobs**: Handle asynchronous operations +- **Traits**: Provide shared functionality (e.g., `ExecuteRemoteCommand`) +- **Helper Functions**: Domain-specific helpers in `bootstrap/helpers/` + +### Database Patterns +- Use Eloquent ORM for database interactions +- Implement relationships properly (HasMany, BelongsTo, etc.) +- Use database transactions for critical operations +- Leverage query scopes for reusable queries +- Apply indexes for performance-critical queries + +### Security Best Practices +- **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum +- **Authorization**: Team-based access control with policies and enhanced form components +- **Form Component Security**: Built-in `canGate` authorization system for UI components +- **API Security**: Token-based auth with IP allowlisting +- **Secrets Management**: Never log or expose sensitive data +- **Input Validation**: Always validate user input with Form Requests or Rules +- **SQL Injection Prevention**: Use Eloquent ORM or parameterized queries + +### API Development +- RESTful endpoints in `routes/api.php` +- Use API Resources for response formatting +- Implement rate limiting for public endpoints +- Version APIs when making breaking changes +- Document endpoints with clear examples + +### Testing Strategy +- **Framework**: Pest for expressive testing +- **Structure**: Feature tests for user flows, Unit tests for isolated logic +- **Coverage**: Test critical paths and edge cases +- **Mocking**: Use Laravel's built-in mocking for external services +- **Database**: Use RefreshDatabase trait for test isolation + +### Routing Conventions +- Group routes by middleware and prefix +- Use route model binding for cleaner controllers +- Name routes consistently (resource.action) +- Implement proper HTTP verbs (GET, POST, PUT, DELETE) + +### Error Handling +- Use `handleError()` helper for consistent error handling +- Log errors with appropriate context +- Return user-friendly error messages +- Implement proper HTTP status codes + +### Performance Considerations +- Use eager loading to prevent N+1 queries +- Implement caching for frequently accessed data +- Queue heavy operations +- Optimize database queries with proper indexes +- Use chunking for large data operations + +### Code Style +- Follow PSR-12 coding standards +- Use Laravel Pint for automatic formatting +- Write descriptive variable and method names +- Keep methods small and focused +- Document complex logic with clear comments + +## Cloud Instance Considerations + +We have a cloud instance of Coolify (hosted version) with: +- 2 Horizon worker servers +- Thousands of connected servers +- Thousands of active users +- High-availability requirements + +When developing features: +- Consider scalability implications +- Test with large datasets +- Implement efficient queries +- Use queues for heavy operations +- Consider rate limiting and resource constraints +- Implement proper error recovery mechanisms + +## Important Reminders + +- Always run code formatting: `./vendor/bin/pint` +- Test your changes: `./vendor/bin/pest` +- Check for static analysis issues: `./vendor/bin/phpstan` +- Use existing patterns and helpers +- Follow the established directory structure +- Maintain backward compatibility +- Document breaking changes +- Consider performance impact on large-scale deployments + +## Additional Documentation + +For more detailed guidelines and patterns, refer to the `.cursor/rules/` directory: + +### Architecture & Patterns +- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure +- [Deployment Architecture](.cursor/rules/deployment-architecture.mdc) - Deployment patterns and flows +- [Database Patterns](.cursor/rules/database-patterns.mdc) - Database design and query patterns +- [Frontend Patterns](.cursor/rules/frontend-patterns.mdc) - Livewire and Alpine.js patterns +- [API & Routing](.cursor/rules/api-and-routing.mdc) - API design and routing conventions + +### Development & Security +- [Development Workflow](.cursor/rules/development-workflow.mdc) - Development best practices +- [Security Patterns](.cursor/rules/security-patterns.mdc) - Security implementation details +- [Form Components](.cursor/rules/form-components.mdc) - Enhanced form components with authorization +- [Testing Patterns](.cursor/rules/testing-patterns.mdc) - Testing strategies and examples + +### Project Information +- [Project Overview](.cursor/rules/project-overview.mdc) - High-level project structure +- [Technology Stack](.cursor/rules/technology-stack.mdc) - Detailed tech stack information +- [Cursor Rules Guide](.cursor/rules/cursor_rules.mdc) - How to maintain cursor rules + +=== + + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.7 +- laravel/fortify (FORTIFY) - v1 +- laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 +- laravel/prompts (PROMPTS) - v0 +- laravel/sanctum (SANCTUM) - v4 +- laravel/socialite (SOCIALITE) - v5 +- livewire/livewire (LIVEWIRE) - v3 +- laravel/dusk (DUSK) - v8 +- laravel/pint (PINT) - v1 +- laravel/telescope (TELESCOPE) - v5 +- pestphp/pest (PEST) - v3 +- phpunit/phpunit (PHPUNIT) - v11 +- rector/rector (RECTOR) - v2 +- laravel-echo (ECHO) - v2 +- tailwindcss (TAILWINDCSS) - v4 +- vue (VUE) - v3 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. ### Testing -- Uses Pest for testing framework -- Tests located in `tests/` directory +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. -### Deployment and Docker -- Applications are deployed using Docker containers -- Configuration generated dynamically based on application settings -- Supports multiple deployment targets and proxy configurations \ No newline at end of file +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure. +- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that. + +### Laravel 10 Structure +- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`. +- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure: + - Middleware registration happens in `app/Http/Kernel.php` + - Exception handling is in `app/Exceptions/Handler.php` + - Console commands and schedule register in `app/Console/Kernel.php` + - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php` + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
+ + +Random other things you should remember: +- App\Models\Application::team must return a relationship instance., always use team() \ No newline at end of file diff --git a/README.md b/README.md index cf3dc21c3..f291a33e8 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Thank you so much! ## Big Sponsors +* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy * [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management * [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform * [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform @@ -87,8 +88,11 @@ Thank you so much! * [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure * [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity + ## Small Sponsors +OpenElements +XamanApp UXWizz Evercam Imre Ujlaki diff --git a/SECURITY.md b/SECURITY.md index 0711bf5b5..e491737ef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,7 +18,7 @@ We take security seriously. Security updates are released as soon as possible af If you discover a security vulnerability, please follow these steps: 1. **DO NOT** disclose the vulnerability publicly. -2. Send a detailed report to: `hi@coollabs.io`. +2. Send a detailed report to: `security@coollabs.io`. 3. Include in your report: - A description of the vulnerability - Steps to reproduce the issue diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 0ca703fce..ee3398b04 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -49,7 +49,7 @@ class StopApplication } if ($dockerCleanup) { - CleanupDocker::dispatch($server, true); + CleanupDocker::dispatch($server, false, false); } } catch (\Exception $e) { return $e->getMessage(); diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index a40eac17b..38d46b3c1 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -185,6 +185,8 @@ class StartPostgresql } } + $command = ['postgres']; + if (filled($this->database->postgres_conf)) { $docker_compose['services'][$container_name]['volumes'] = array_merge( $docker_compose['services'][$container_name]['volumes'], @@ -195,29 +197,25 @@ class StartPostgresql 'read_only' => true, ]] ); - $docker_compose['services'][$container_name]['command'] = [ - 'postgres', - '-c', - 'config_file=/etc/postgresql/postgresql.conf', - ]; + $command = array_merge($command, ['-c', 'config_file=/etc/postgresql/postgresql.conf']); } if ($this->database->enable_ssl) { - $docker_compose['services'][$container_name]['command'] = [ - 'postgres', - '-c', - 'ssl=on', - '-c', - 'ssl_cert_file=/var/lib/postgresql/certs/server.crt', - '-c', - 'ssl_key_file=/var/lib/postgresql/certs/server.key', - ]; + $command = array_merge($command, [ + '-c', 'ssl=on', + '-c', 'ssl_cert_file=/var/lib/postgresql/certs/server.crt', + '-c', 'ssl_key_file=/var/lib/postgresql/certs/server.key', + ]); } // Add custom docker run options $docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options); $docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network); + if (count($command) > 1) { + $docker_compose['services'][$container_name]['command'] = $command; + } + $docker_compose = Yaml::dump($docker_compose, 10); $docker_compose_base64 = base64_encode($docker_compose); $this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null"; @@ -231,6 +229,8 @@ class StartPostgresql } $this->commands[] = "echo 'Database started.'"; + ray($this->commands); + return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); } diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index a03c9269e..5c881e743 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -18,7 +18,7 @@ class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true) { try { $server = $database->destination->server; @@ -29,7 +29,7 @@ class StopDatabase $this->stopContainer($database, $database->uuid, 30); if ($dockerCleanup) { - CleanupDocker::dispatch($server, true); + CleanupDocker::dispatch($server, false, false); } if ($database->is_public) { diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index c3268ec07..f5d5f82b6 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -26,6 +26,8 @@ class GetContainersStatus public $server; + protected ?Collection $applicationContainerStatuses; + public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null) { $this->containers = $containers; @@ -94,7 +96,11 @@ class GetContainersStatus } $containerStatus = data_get($container, 'State.Status'); $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); - $containerStatus = "$containerStatus ($containerHealth)"; + if ($containerStatus === 'restarting') { + $containerStatus = "restarting ($containerHealth)"; + } else { + $containerStatus = "$containerStatus ($containerHealth)"; + } $labels = Arr::undot(format_docker_labels_to_json($labels)); $applicationId = data_get($labels, 'coolify.applicationId'); if ($applicationId) { @@ -119,11 +125,16 @@ class GetContainersStatus $application = $this->applications->where('id', $applicationId)->first(); if ($application) { $foundApplications[] = $application->id; - $statusFromDb = $application->status; - if ($statusFromDb !== $containerStatus) { - $application->update(['status' => $containerStatus]); - } else { - $application->update(['last_online_at' => now()]); + // Store container status for aggregation + if (! isset($this->applicationContainerStatuses)) { + $this->applicationContainerStatuses = collect(); + } + if (! $this->applicationContainerStatuses->has($applicationId)) { + $this->applicationContainerStatuses->put($applicationId, collect()); + } + $containerName = data_get($labels, 'com.docker.compose.service'); + if ($containerName) { + $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus); } } else { // Notify user that this container should not be there. @@ -320,6 +331,97 @@ class GetContainersStatus } // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); } + + // Aggregate multi-container application statuses + if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) { + foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { + $application = $this->applications->where('id', $applicationId)->first(); + if (! $application) { + continue; + } + + $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses); + if ($aggregatedStatus) { + $statusFromDb = $application->status; + if ($statusFromDb !== $aggregatedStatus) { + $application->update(['status' => $aggregatedStatus]); + } else { + $application->update(['last_online_at' => now()]); + } + } + } + } + ServiceChecked::dispatch($this->server->team->id); } + + private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string + { + // Parse docker compose to check for excluded containers + $dockerComposeRaw = data_get($application, 'docker_compose_raw'); + $excludedContainers = collect(); + + if ($dockerComposeRaw) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $services = data_get($dockerCompose, 'services', []); + + foreach ($services as $serviceName => $serviceConfig) { + // Check if container should be excluded + $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false); + $restartPolicy = data_get($serviceConfig, 'restart', 'always'); + + if ($excludeFromHc || $restartPolicy === 'no') { + $excludedContainers->push($serviceName); + } + } + } catch (\Exception $e) { + // If we can't parse, treat all containers as included + } + } + + // Filter out excluded containers + $relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) { + return ! $excludedContainers->contains($containerName); + }); + + // If all containers are excluded, don't update status + if ($relevantStatuses->isEmpty()) { + return null; + } + + $hasRunning = false; + $hasRestarting = false; + $hasUnhealthy = false; + $hasExited = false; + + foreach ($relevantStatuses as $status) { + if (str($status)->contains('restarting')) { + $hasRestarting = true; + } elseif (str($status)->contains('running')) { + $hasRunning = true; + if (str($status)->contains('unhealthy')) { + $hasUnhealthy = true; + } + } elseif (str($status)->contains('exited')) { + $hasExited = true; + $hasUnhealthy = true; + } + } + + if ($hasRestarting) { + return 'degraded (unhealthy)'; + } + + if ($hasRunning && $hasExited) { + return 'degraded (unhealthy)'; + } + + if ($hasRunning) { + return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)'; + } + + // All containers are exited + return 'exited (unhealthy)'; + } } diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index ea2befd3a..9f97dd0d4 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -40,7 +40,7 @@ class CreateNewUser implements CreatesNewUsers $user = User::create([ 'id' => 0, 'name' => $input['name'], - 'email' => strtolower($input['email']), + 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); $team = $user->teams()->first(); @@ -52,7 +52,7 @@ class CreateNewUser implements CreatesNewUsers } else { $user = User::create([ 'name' => $input['name'], - 'email' => strtolower($input['email']), + 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); $team = $user->teams()->first(); diff --git a/app/Actions/Proxy/CheckConfiguration.php b/app/Actions/Proxy/CheckConfiguration.php deleted file mode 100644 index b2d1eb787..000000000 --- a/app/Actions/Proxy/CheckConfiguration.php +++ /dev/null @@ -1,36 +0,0 @@ -proxyType(); - if ($proxyType === 'NONE') { - return 'OK'; - } - $proxy_path = $server->proxyPath(); - $payload = [ - "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml", - ]; - $proxy_configuration = instant_remote_process($payload, $server, false); - if ($reset || ! $proxy_configuration || is_null($proxy_configuration)) { - $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value(); - } - if (! $proxy_configuration || is_null($proxy_configuration)) { - throw new \Exception('Could not generate proxy configuration'); - } - - ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration); - - return $proxy_configuration; - } -} diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index d4b03ffc1..99537e606 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -66,11 +66,11 @@ class CheckProxy if ($server->id === 0) { $ip = 'host.docker.internal'; } - $portsToCheck = ['80', '443']; + $portsToCheck = []; try { if ($server->proxyType() !== ProxyTypes::NONE->value) { - $proxyCompose = CheckConfiguration::run($server); + $proxyCompose = GetProxyConfiguration::run($server); if (isset($proxyCompose)) { $yaml = Yaml::parse($proxyCompose); $configPorts = []; diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php new file mode 100644 index 000000000..3bf91c281 --- /dev/null +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -0,0 +1,47 @@ +proxyType(); + if ($proxyType === 'NONE') { + return 'OK'; + } + + $proxy_path = $server->proxyPath(); + $proxy_configuration = null; + + // If not forcing regeneration, try to read existing configuration + if (! $forceRegenerate) { + $payload = [ + "mkdir -p $proxy_path", + "cat $proxy_path/docker-compose.yml 2>/dev/null", + ]; + $proxy_configuration = instant_remote_process($payload, $server, false); + } + + // Generate default configuration if: + // 1. Force regenerate is requested + // 2. Configuration file doesn't exist or is empty + if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) { + $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value(); + } + + if (empty($proxy_configuration)) { + throw new \Exception('Could not get or generate proxy configuration'); + } + + ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration); + + return $proxy_configuration; + } +} diff --git a/app/Actions/Proxy/SaveConfiguration.php b/app/Actions/Proxy/SaveProxyConfiguration.php similarity index 59% rename from app/Actions/Proxy/SaveConfiguration.php rename to app/Actions/Proxy/SaveProxyConfiguration.php index f2de2b3f5..53fbecce2 100644 --- a/app/Actions/Proxy/SaveConfiguration.php +++ b/app/Actions/Proxy/SaveProxyConfiguration.php @@ -5,22 +5,21 @@ namespace App\Actions\Proxy; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; -class SaveConfiguration +class SaveProxyConfiguration { use AsAction; - public function handle(Server $server, ?string $proxy_settings = null) + public function handle(Server $server, string $configuration): void { - if (is_null($proxy_settings)) { - $proxy_settings = CheckConfiguration::run($server, true); - } $proxy_path = $server->proxyPath(); - $docker_compose_yml_base64 = base64_encode($proxy_settings); + $docker_compose_yml_base64 = base64_encode($configuration); + // Update the saved settings hash $server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value; $server->save(); - return instant_remote_process([ + // Transfer the configuration file to the server + instant_remote_process([ "mkdir -p $proxy_path", "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null", ], $server); diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index e7c020ff6..ecfb13d0b 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -21,11 +21,11 @@ class StartProxy } $commands = collect([]); $proxy_path = $server->proxyPath(); - $configuration = CheckConfiguration::run($server); + $configuration = GetProxyConfiguration::run($server); if (! $configuration) { throw new \Exception('Configuration is not synced'); } - SaveConfiguration::run($server, $configuration); + SaveProxyConfiguration::run($server, $configuration); $docker_compose_yml_base64 = base64_encode($configuration); $server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); $server->save(); diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index a8b1be11d..6823dfb92 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -102,7 +102,6 @@ class CheckUpdates ]; } } catch (\Throwable $e) { - ray('Error:', $e->getMessage()); return [ 'osId' => $osId, diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 754feecb1..392562167 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -11,7 +11,7 @@ class CleanupDocker public string $jobQueue = 'high'; - public function handle(Server $server) + public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false) { $settings = instanceSettings(); $realtimeImage = config('constants.coolify.realtime_image'); @@ -36,11 +36,11 @@ class CleanupDocker "docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", ]; - if ($server->settings->delete_unused_volumes) { + if ($deleteUnusedVolumes) { $commands[] = 'docker volume prune -af'; } - if ($server->settings->delete_unused_networks) { + if ($deleteUnusedNetworks) { $commands[] = 'docker network prune -f'; } diff --git a/app/Actions/Server/ServerCheck.php b/app/Actions/Server/ServerCheck.php deleted file mode 100644 index 6ac87f1f0..000000000 --- a/app/Actions/Server/ServerCheck.php +++ /dev/null @@ -1,268 +0,0 @@ -server = $server; - try { - if ($this->server->isFunctional() === false) { - return 'Server is not functional.'; - } - - if (! $this->server->isSwarmWorker() && ! $this->server->isBuildServer()) { - - if (isset($data)) { - $data = collect($data); - - $this->server->sentinelHeartbeat(); - - $this->containers = collect(data_get($data, 'containers')); - - $filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage'); - ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot); - - $containerReplicates = null; - $this->isSentinel = true; - } else { - ['containers' => $this->containers, 'containerReplicates' => $containerReplicates] = $this->server->getContainers(); - // ServerStorageCheckJob::dispatch($this->server); - } - - if (is_null($this->containers)) { - return 'No containers found.'; - } - - if (isset($containerReplicates)) { - foreach ($containerReplicates as $containerReplica) { - $name = data_get($containerReplica, 'Name'); - $this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) { - if (data_get($container, 'Spec.Name') === $name) { - $replicas = data_get($containerReplica, 'Replicas'); - $running = str($replicas)->explode('/')[0]; - $total = str($replicas)->explode('/')[1]; - if ($running === $total) { - data_set($container, 'State.Status', 'running'); - data_set($container, 'State.Health.Status', 'healthy'); - } else { - data_set($container, 'State.Status', 'starting'); - data_set($container, 'State.Health.Status', 'unhealthy'); - } - } - - return $container; - }); - } - } - $this->checkContainers(); - - if ($this->server->isSentinelEnabled() && $this->isSentinel === false) { - CheckAndStartSentinelJob::dispatch($this->server); - } - - if ($this->server->isLogDrainEnabled()) { - $this->checkLogDrainContainer(); - } - - if ($this->server->proxySet() && ! $this->server->proxy->force_stop) { - $foundProxyContainer = $this->containers->filter(function ($value, $key) { - if ($this->server->isSwarm()) { - return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik'; - } else { - return data_get($value, 'Name') === '/coolify-proxy'; - } - })->first(); - $proxyStatus = data_get($foundProxyContainer, 'State.Status', 'exited'); - if (! $foundProxyContainer || $proxyStatus !== 'running') { - try { - $shouldStart = CheckProxy::run($this->server); - if ($shouldStart) { - StartProxy::run($this->server, async: false); - $this->server->team?->notify(new ContainerRestarted('coolify-proxy', $this->server)); - } - } catch (\Throwable $e) { - } - } else { - $this->server->proxy->status = data_get($foundProxyContainer, 'State.Status'); - $this->server->save(); - $connectProxyToDockerNetworks = connectProxyToNetworks($this->server); - instant_remote_process($connectProxyToDockerNetworks, $this->server, false); - } - } - } - } catch (\Throwable $e) { - return handleError($e); - } - } - - private function checkLogDrainContainer() - { - $foundLogDrainContainer = $this->containers->filter(function ($value, $key) { - return data_get($value, 'Name') === '/coolify-log-drain'; - })->first(); - if ($foundLogDrainContainer) { - $status = data_get($foundLogDrainContainer, 'State.Status'); - if ($status !== 'running') { - StartLogDrain::dispatch($this->server); - } - } else { - StartLogDrain::dispatch($this->server); - } - } - - private function checkContainers() - { - foreach ($this->containers as $container) { - if ($this->isSentinel) { - $labels = Arr::undot(data_get($container, 'labels')); - } else { - if ($this->server->isSwarm()) { - $labels = Arr::undot(data_get($container, 'Spec.Labels')); - } else { - $labels = Arr::undot(data_get($container, 'Config.Labels')); - } - } - $managed = data_get($labels, 'coolify.managed'); - if (! $managed) { - continue; - } - $uuid = data_get($labels, 'coolify.name'); - if (! $uuid) { - $uuid = data_get($labels, 'com.docker.compose.service'); - } - - if ($this->isSentinel) { - $containerStatus = data_get($container, 'state'); - $containerHealth = data_get($container, 'health_status'); - } else { - $containerStatus = data_get($container, 'State.Status'); - $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); - } - $containerStatus = "$containerStatus ($containerHealth)"; - - $applicationId = data_get($labels, 'coolify.applicationId'); - $serviceId = data_get($labels, 'coolify.serviceId'); - $databaseId = data_get($labels, 'coolify.databaseId'); - $pullRequestId = data_get($labels, 'coolify.pullRequestId'); - - if ($applicationId) { - // Application - if ($pullRequestId != 0) { - if (str($applicationId)->contains('-')) { - $applicationId = str($applicationId)->before('-'); - } - $preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first(); - if ($preview) { - $preview->update(['status' => $containerStatus]); - } - } else { - $application = Application::where('id', $applicationId)->first(); - if ($application) { - $application->update([ - 'status' => $containerStatus, - 'last_online_at' => now(), - ]); - } - } - } elseif (isset($serviceId)) { - // Service - $subType = data_get($labels, 'coolify.service.subType'); - $subId = data_get($labels, 'coolify.service.subId'); - $service = Service::where('id', $serviceId)->first(); - if (! $service) { - continue; - } - if ($subType === 'application') { - $service = ServiceApplication::where('id', $subId)->first(); - } else { - $service = ServiceDatabase::where('id', $subId)->first(); - } - if ($service) { - $service->update([ - 'status' => $containerStatus, - 'last_online_at' => now(), - ]); - if ($subType === 'database') { - $isPublic = data_get($service, 'is_public'); - if ($isPublic) { - $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { - if ($this->isSentinel) { - return data_get($value, 'name') === $uuid.'-proxy'; - } else { - - if ($this->server->isSwarm()) { - return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid"; - } else { - return data_get($value, 'Name') === "/$uuid-proxy"; - } - } - })->first(); - if (! $foundTcpProxy) { - StartDatabaseProxy::run($service); - } - } - } - } - } else { - // Database - if (is_null($this->databases)) { - $this->databases = $this->server->databases(); - } - $database = $this->databases->where('uuid', $uuid)->first(); - if ($database) { - $database->update([ - 'status' => $containerStatus, - 'last_online_at' => now(), - ]); - - $isPublic = data_get($database, 'is_public'); - if ($isPublic) { - $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { - if ($this->isSentinel) { - return data_get($value, 'name') === $uuid.'-proxy'; - } else { - if ($this->server->isSwarm()) { - return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid"; - } else { - - return data_get($value, 'Name') === "/$uuid-proxy"; - } - } - })->first(); - if (! $foundTcpProxy) { - StartDatabaseProxy::run($database); - // $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server)); - } - } - } - } - } - } -} diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 1ecf882dc..1f248aec1 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Events\SentinelRestarted; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; @@ -9,7 +10,7 @@ class StartSentinel { use AsAction; - public function handle(Server $server, bool $restart = false, ?string $latestVersion = null) + public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null) { if ($server->isSwarm() || $server->isBuildServer()) { return; @@ -43,7 +44,9 @@ class StartSentinel ]; if (isDev()) { // data_set($environments, 'DEBUG', 'true'); - // $image = 'sentinel'; + if ($customImage && ! empty($customImage)) { + $image = $customImage; + } $mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel'; } $dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"'; @@ -61,5 +64,8 @@ class StartSentinel $server->settings->is_sentinel_enabled = true; $server->settings->save(); $server->sentinelHeartbeat(); + + // Dispatch event to notify UI components + SentinelRestarted::dispatch($server, $version); } } diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index 9a6cc140b..2a06428e2 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -29,7 +29,7 @@ class UpdateCoolify if (! $this->server) { return; } - CleanupDocker::dispatch($this->server); + CleanupDocker::dispatch($this->server, false, false); $this->latestVersion = get_latest_version_of_coolify(); $this->currentVersion = config('constants.coolify.version'); if (! $manual_update) { diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 404e11559..8790901cd 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -11,7 +11,7 @@ class DeleteService { use AsAction; - public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks) + public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup) { try { $server = data_get($service, 'server'); @@ -71,7 +71,7 @@ class DeleteService $service->forceDelete(); if ($dockerCleanup) { - CleanupDocker::dispatch($server, true); + CleanupDocker::dispatch($server, false, false); } } } diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index a7fa4b8b2..3f4e96479 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -14,7 +14,7 @@ class StopService public string $jobQueue = 'high'; - public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true) + public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true) { try { $server = $service->destination->server; @@ -36,11 +36,11 @@ class StopService $this->stopContainersInParallel($containersToStop, $server); } - if ($isDeleteOperation) { + if ($deleteConnectedNetworks) { $service->deleteConnectedNetworks(); } if ($dockerCleanup) { - CleanupDocker::dispatch($server, true); + CleanupDocker::dispatch($server, false, false); } } catch (\Exception $e) { return $e->getMessage(); diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php index 5a7ba6637..e06136e3c 100644 --- a/app/Actions/Shared/ComplexStatusCheck.php +++ b/app/Actions/Shared/ComplexStatusCheck.php @@ -26,22 +26,22 @@ class ComplexStatusCheck continue; } } - $container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false); - $container = format_docker_command_output_to_json($container); - if ($container->count() === 1) { - $container = $container->first(); - $containerStatus = data_get($container, 'State.Status'); - $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); + $containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false); + $containers = format_docker_command_output_to_json($containers); + + if ($containers->count() > 0) { + $statusToSet = $this->aggregateContainerStatuses($application, $containers); + if ($is_main_server) { $statusFromDb = $application->status; - if ($statusFromDb !== $containerStatus) { - $application->update(['status' => "$containerStatus:$containerHealth"]); + if ($statusFromDb !== $statusToSet) { + $application->update(['status' => $statusToSet]); } } else { $additional_server = $application->additional_servers()->wherePivot('server_id', $server->id); $statusFromDb = $additional_server->first()->pivot->status; - if ($statusFromDb !== $containerStatus) { - $additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]); + if ($statusFromDb !== $statusToSet) { + $additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]); } } } else { @@ -57,4 +57,78 @@ class ComplexStatusCheck } } } + + private function aggregateContainerStatuses($application, $containers) + { + $dockerComposeRaw = data_get($application, 'docker_compose_raw'); + $excludedContainers = collect(); + + if ($dockerComposeRaw) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $services = data_get($dockerCompose, 'services', []); + + foreach ($services as $serviceName => $serviceConfig) { + $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false); + $restartPolicy = data_get($serviceConfig, 'restart', 'always'); + + if ($excludeFromHc || $restartPolicy === 'no') { + $excludedContainers->push($serviceName); + } + } + } catch (\Exception $e) { + // If we can't parse, treat all containers as included + } + } + + $hasRunning = false; + $hasRestarting = false; + $hasUnhealthy = false; + $hasExited = false; + $relevantContainerCount = 0; + + foreach ($containers as $container) { + $labels = data_get($container, 'Config.Labels', []); + $serviceName = data_get($labels, 'com.docker.compose.service'); + + if ($serviceName && $excludedContainers->contains($serviceName)) { + continue; + } + + $relevantContainerCount++; + $containerStatus = data_get($container, 'State.Status'); + $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); + + if ($containerStatus === 'restarting') { + $hasRestarting = true; + $hasUnhealthy = true; + } elseif ($containerStatus === 'running') { + $hasRunning = true; + if ($containerHealth === 'unhealthy') { + $hasUnhealthy = true; + } + } elseif ($containerStatus === 'exited') { + $hasExited = true; + $hasUnhealthy = true; + } + } + + if ($relevantContainerCount === 0) { + return 'running:healthy'; + } + + if ($hasRestarting) { + return 'degraded:unhealthy'; + } + + if ($hasRunning && $hasExited) { + return 'degraded:unhealthy'; + } + + if ($hasRunning) { + return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy'; + } + + return 'exited:unhealthy'; + } } diff --git a/app/Actions/Stripe/CancelSubscription.php b/app/Actions/Stripe/CancelSubscription.php new file mode 100644 index 000000000..859aec6f6 --- /dev/null +++ b/app/Actions/Stripe/CancelSubscription.php @@ -0,0 +1,151 @@ +user = $user; + $this->isDryRun = $isDryRun; + + if (! $isDryRun && isCloud()) { + $this->stripe = new StripeClient(config('subscription.stripe_api_key')); + } + } + + public function getSubscriptionsPreview(): Collection + { + $subscriptions = collect(); + + // Get all teams the user belongs to + $teams = $this->user->teams; + + foreach ($teams as $team) { + // Only include subscriptions from teams where user is owner + $userRole = $team->pivot->role; + if ($userRole === 'owner' && $team->subscription) { + $subscription = $team->subscription; + + // Only include active subscriptions + if ($subscription->stripe_subscription_id && + $subscription->stripe_invoice_paid) { + $subscriptions->push($subscription); + } + } + } + + return $subscriptions; + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'cancelled' => 0, + 'failed' => 0, + 'errors' => [], + ]; + } + + $cancelledCount = 0; + $failedCount = 0; + $errors = []; + + $subscriptions = $this->getSubscriptionsPreview(); + + foreach ($subscriptions as $subscription) { + try { + $this->cancelSingleSubscription($subscription); + $cancelledCount++; + } catch (\Exception $e) { + $failedCount++; + $errorMessage = "Failed to cancel subscription {$subscription->stripe_subscription_id}: ".$e->getMessage(); + $errors[] = $errorMessage; + \Log::error($errorMessage); + } + } + + return [ + 'cancelled' => $cancelledCount, + 'failed' => $failedCount, + 'errors' => $errors, + ]; + } + + private function cancelSingleSubscription(Subscription $subscription): void + { + if (! $this->stripe) { + throw new \Exception('Stripe client not initialized'); + } + + $subscriptionId = $subscription->stripe_subscription_id; + + // Cancel the subscription immediately (not at period end) + $this->stripe->subscriptions->cancel($subscriptionId, []); + + // Update local database + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + 'stripe_feedback' => 'User account deleted', + 'stripe_comment' => 'Subscription cancelled due to user account deletion at '.now()->toDateTimeString(), + ]); + + // Call the team's subscription ended method to handle cleanup + if ($subscription->team) { + $subscription->team->subscriptionEnded(); + } + + \Log::info("Cancelled Stripe subscription: {$subscriptionId} for team: {$subscription->team->name}"); + } + + /** + * Cancel a single subscription by ID (helper method for external use) + */ + public static function cancelById(string $subscriptionId): bool + { + try { + if (! isCloud()) { + return false; + } + + $stripe = new StripeClient(config('subscription.stripe_api_key')); + $stripe->subscriptions->cancel($subscriptionId, []); + + // Update local record if exists + $subscription = Subscription::where('stripe_subscription_id', $subscriptionId)->first(); + if ($subscription) { + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + ]); + + if ($subscription->team) { + $subscription->team->subscriptionEnded(); + } + } + + return true; + } catch (\Exception $e) { + \Log::error("Failed to cancel subscription {$subscriptionId}: ".$e->getMessage()); + + return false; + } + } +} diff --git a/app/Actions/User/DeleteUserResources.php b/app/Actions/User/DeleteUserResources.php new file mode 100644 index 000000000..7b2e7318d --- /dev/null +++ b/app/Actions/User/DeleteUserResources.php @@ -0,0 +1,125 @@ +user = $user; + $this->isDryRun = $isDryRun; + } + + public function getResourcesPreview(): array + { + $applications = collect(); + $databases = collect(); + $services = collect(); + + // Get all teams the user belongs to + $teams = $this->user->teams; + + foreach ($teams as $team) { + // Get all servers for this team + $servers = $team->servers; + + foreach ($servers as $server) { + // Get applications + $serverApplications = $server->applications; + $applications = $applications->merge($serverApplications); + + // Get databases + $serverDatabases = $this->getAllDatabasesForServer($server); + $databases = $databases->merge($serverDatabases); + + // Get services + $serverServices = $server->services; + $services = $services->merge($serverServices); + } + } + + return [ + 'applications' => $applications->unique('id'), + 'databases' => $databases->unique('id'), + 'services' => $services->unique('id'), + ]; + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'applications' => 0, + 'databases' => 0, + 'services' => 0, + ]; + } + + $deletedCounts = [ + 'applications' => 0, + 'databases' => 0, + 'services' => 0, + ]; + + $resources = $this->getResourcesPreview(); + + // Delete applications + foreach ($resources['applications'] as $application) { + try { + $application->forceDelete(); + $deletedCounts['applications']++; + } catch (\Exception $e) { + \Log::error("Failed to delete application {$application->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Delete databases + foreach ($resources['databases'] as $database) { + try { + $database->forceDelete(); + $deletedCounts['databases']++; + } catch (\Exception $e) { + \Log::error("Failed to delete database {$database->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Delete services + foreach ($resources['services'] as $service) { + try { + $service->forceDelete(); + $deletedCounts['services']++; + } catch (\Exception $e) { + \Log::error("Failed to delete service {$service->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + return $deletedCounts; + } + + private function getAllDatabasesForServer($server): Collection + { + $databases = collect(); + + // Get all standalone database types + $databases = $databases->merge($server->postgresqls); + $databases = $databases->merge($server->mysqls); + $databases = $databases->merge($server->mariadbs); + $databases = $databases->merge($server->mongodbs); + $databases = $databases->merge($server->redis); + $databases = $databases->merge($server->keydbs); + $databases = $databases->merge($server->dragonflies); + $databases = $databases->merge($server->clickhouses); + + return $databases; + } +} diff --git a/app/Actions/User/DeleteUserServers.php b/app/Actions/User/DeleteUserServers.php new file mode 100644 index 000000000..d8caae54d --- /dev/null +++ b/app/Actions/User/DeleteUserServers.php @@ -0,0 +1,77 @@ +user = $user; + $this->isDryRun = $isDryRun; + } + + public function getServersPreview(): Collection + { + $servers = collect(); + + // Get all teams the user belongs to + $teams = $this->user->teams; + + foreach ($teams as $team) { + // Only include servers from teams where user is owner or admin + $userRole = $team->pivot->role; + if ($userRole === 'owner' || $userRole === 'admin') { + $teamServers = $team->servers; + $servers = $servers->merge($teamServers); + } + } + + // Return unique servers (in case same server is in multiple teams) + return $servers->unique('id'); + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'servers' => 0, + ]; + } + + $deletedCount = 0; + + $servers = $this->getServersPreview(); + + foreach ($servers as $server) { + try { + // Skip the default server (ID 0) which is the Coolify host + if ($server->id === 0) { + \Log::info('Skipping deletion of Coolify host server (ID: 0)'); + + continue; + } + + // The Server model's forceDeleting event will handle cleanup of: + // - destinations + // - settings + $server->forceDelete(); + $deletedCount++; + } catch (\Exception $e) { + \Log::error("Failed to delete server {$server->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + return [ + 'servers' => $deletedCount, + ]; + } +} diff --git a/app/Actions/User/DeleteUserTeams.php b/app/Actions/User/DeleteUserTeams.php new file mode 100644 index 000000000..d572db9e7 --- /dev/null +++ b/app/Actions/User/DeleteUserTeams.php @@ -0,0 +1,202 @@ +user = $user; + $this->isDryRun = $isDryRun; + } + + public function getTeamsPreview(): array + { + $teamsToDelete = collect(); + $teamsToTransfer = collect(); + $teamsToLeave = collect(); + $edgeCases = collect(); + + $teams = $this->user->teams; + + foreach ($teams as $team) { + // Skip root team (ID 0) + if ($team->id === 0) { + continue; + } + + $userRole = $team->pivot->role; + $memberCount = $team->members->count(); + + if ($memberCount === 1) { + // User is alone in the team - delete it + $teamsToDelete->push($team); + } elseif ($userRole === 'owner') { + // Check if there are other owners + $otherOwners = $team->members + ->where('id', '!=', $this->user->id) + ->filter(function ($member) { + return $member->pivot->role === 'owner'; + }); + + if ($otherOwners->isNotEmpty()) { + // There are other owners, but check if this user is paying for the subscription + if ($this->isUserPayingForTeamSubscription($team)) { + // User is paying for the subscription - this is an edge case + $edgeCases->push([ + 'team' => $team, + 'reason' => 'User is paying for the team\'s Stripe subscription but there are other owners. The subscription needs to be cancelled or transferred to another owner\'s payment method.', + ]); + } else { + // There are other owners and user is not paying, just remove this user + $teamsToLeave->push($team); + } + } else { + // User is the only owner, check for replacement + $newOwner = $this->findNewOwner($team); + if ($newOwner) { + $teamsToTransfer->push([ + 'team' => $team, + 'new_owner' => $newOwner, + ]); + } else { + // No suitable replacement found - this is an edge case + $edgeCases->push([ + 'team' => $team, + 'reason' => 'No suitable owner replacement found. Team has only regular members without admin privileges.', + ]); + } + } + } else { + // User is just a member - remove them from the team + $teamsToLeave->push($team); + } + } + + return [ + 'to_delete' => $teamsToDelete, + 'to_transfer' => $teamsToTransfer, + 'to_leave' => $teamsToLeave, + 'edge_cases' => $edgeCases, + ]; + } + + public function execute(): array + { + if ($this->isDryRun) { + return [ + 'deleted' => 0, + 'transferred' => 0, + 'left' => 0, + ]; + } + + $counts = [ + 'deleted' => 0, + 'transferred' => 0, + 'left' => 0, + ]; + + $preview = $this->getTeamsPreview(); + + // Check for edge cases - should not happen here as we check earlier, but be safe + if ($preview['edge_cases']->isNotEmpty()) { + throw new \Exception('Edge cases detected during execution. This should not happen.'); + } + + // Delete teams where user is alone + foreach ($preview['to_delete'] as $team) { + try { + // The Team model's deleting event will handle cleanup of: + // - private keys + // - sources + // - tags + // - environment variables + // - s3 storages + // - notification settings + $team->delete(); + $counts['deleted']++; + } catch (\Exception $e) { + \Log::error("Failed to delete team {$team->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Transfer ownership for teams where user is owner but not alone + foreach ($preview['to_transfer'] as $item) { + try { + $team = $item['team']; + $newOwner = $item['new_owner']; + + // Update the new owner's role to owner + $team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']); + + // Remove the current user from the team + $team->members()->detach($this->user->id); + + $counts['transferred']++; + } catch (\Exception $e) { + \Log::error("Failed to transfer ownership of team {$item['team']->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + // Remove user from teams where they're just a member + foreach ($preview['to_leave'] as $team) { + try { + $team->members()->detach($this->user->id); + $counts['left']++; + } catch (\Exception $e) { + \Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage()); + throw $e; // Re-throw to trigger rollback + } + } + + return $counts; + } + + private function findNewOwner(Team $team): ?User + { + // Only look for admins as potential new owners + // We don't promote regular members automatically + $otherAdmin = $team->members + ->where('id', '!=', $this->user->id) + ->filter(function ($member) { + return $member->pivot->role === 'admin'; + }) + ->first(); + + return $otherAdmin; + } + + private function isUserPayingForTeamSubscription(Team $team): bool + { + if (! $team->subscription || ! $team->subscription->stripe_customer_id) { + return false; + } + + // In Stripe, we need to check if the customer email matches the user's email + // This would require a Stripe API call to get customer details + // For now, we'll check if the subscription was created by this user + + // Alternative approach: Check if user is the one who initiated the subscription + // We could store this information when the subscription is created + // For safety, we'll assume if there's an active subscription and multiple owners, + // we should treat it as an edge case that needs manual review + + if ($team->subscription->stripe_subscription_id && + $team->subscription->stripe_invoice_paid) { + // Active subscription exists - we should be cautious + return true; + } + + return false; + } +} diff --git a/app/Console/Commands/CleanupDatabase.php b/app/Console/Commands/CleanupDatabase.php index 2ccb76529..347ea9419 100644 --- a/app/Console/Commands/CleanupDatabase.php +++ b/app/Console/Commands/CleanupDatabase.php @@ -64,13 +64,5 @@ class CleanupDatabase extends Command if ($this->option('yes')) { $scheduled_task_executions->delete(); } - - // Cleanup webhooks table - $webhooks = DB::table('webhooks')->where('created_at', '<', now()->subDays($keep_days)); - $count = $webhooks->count(); - echo "Delete $count entries from webhooks.\n"; - if ($this->option('yes')) { - $webhooks->delete(); - } } } diff --git a/app/Console/Commands/CleanupNames.php b/app/Console/Commands/CleanupNames.php new file mode 100644 index 000000000..2992e32b9 --- /dev/null +++ b/app/Console/Commands/CleanupNames.php @@ -0,0 +1,248 @@ + Project::class, + 'Environment' => Environment::class, + 'Application' => Application::class, + 'Service' => Service::class, + 'Server' => Server::class, + 'Team' => Team::class, + 'StandalonePostgresql' => StandalonePostgresql::class, + 'StandaloneMysql' => StandaloneMysql::class, + 'StandaloneRedis' => StandaloneRedis::class, + 'StandaloneMongodb' => StandaloneMongodb::class, + 'StandaloneMariadb' => StandaloneMariadb::class, + 'StandaloneKeydb' => StandaloneKeydb::class, + 'StandaloneDragonfly' => StandaloneDragonfly::class, + 'StandaloneClickhouse' => StandaloneClickhouse::class, + 'S3Storage' => S3Storage::class, + 'Tag' => Tag::class, + 'PrivateKey' => PrivateKey::class, + 'ScheduledTask' => ScheduledTask::class, + ]; + + protected array $changes = []; + + protected int $totalProcessed = 0; + + protected int $totalCleaned = 0; + + public function handle(): int + { + $this->info('🔍 Scanning for invalid characters in name fields...'); + + if ($this->option('backup') && ! $this->option('dry-run')) { + $this->createBackup(); + } + + $modelFilter = $this->option('model'); + $modelsToProcess = $modelFilter + ? [$modelFilter => $this->modelsToClean[$modelFilter] ?? null] + : $this->modelsToClean; + + if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) { + $this->error("❌ Unknown model: {$modelFilter}"); + $this->info('Available models: '.implode(', ', array_keys($this->modelsToClean))); + + return self::FAILURE; + } + + foreach ($modelsToProcess as $modelName => $modelClass) { + if (! $modelClass) { + continue; + } + $this->processModel($modelName, $modelClass); + } + + $this->displaySummary(); + + if (! $this->option('dry-run') && $this->totalCleaned > 0) { + $this->logChanges(); + } + + return self::SUCCESS; + } + + protected function processModel(string $modelName, string $modelClass): void + { + $this->info("\n📋 Processing {$modelName}..."); + + try { + $records = $modelClass::all(['id', 'name']); + $cleaned = 0; + + foreach ($records as $record) { + $this->totalProcessed++; + + $originalName = $record->name; + $sanitizedName = $this->sanitizeName($originalName); + + if ($sanitizedName !== $originalName) { + $this->changes[] = [ + 'model' => $modelName, + 'id' => $record->id, + 'original' => $originalName, + 'sanitized' => $sanitizedName, + 'timestamp' => now(), + ]; + + if (! $this->option('dry-run')) { + // Update without triggering events/mutators to avoid conflicts + $modelClass::where('id', $record->id)->update(['name' => $sanitizedName]); + } + + $cleaned++; + $this->totalCleaned++; + + $this->warn(" 🧹 {$modelName} #{$record->id}:"); + $this->line(' From: '.$this->truncate($originalName, 80)); + $this->line(' To: '.$this->truncate($sanitizedName, 80)); + } + } + + if ($cleaned > 0) { + $action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized'; + $this->info(" ✅ {$cleaned}/{$records->count()} records {$action}"); + } else { + $this->info(' ✨ No invalid characters found'); + } + + } catch (\Exception $e) { + $this->error(" ❌ Error processing {$modelName}: ".$e->getMessage()); + } + } + + protected function sanitizeName(string $name): string + { + // Remove all characters that don't match the allowed pattern + // Use the shared ValidationPatterns to ensure consistency + $allowedPattern = str_replace(['/', '^', '$'], '', ValidationPatterns::NAME_PATTERN); + $sanitized = preg_replace('/[^'.$allowedPattern.']+/', '', $name); + + // Clean up excessive whitespace but preserve other allowed characters + $sanitized = preg_replace('/\s+/', ' ', $sanitized); + $sanitized = trim($sanitized); + + // If result is empty, provide a default name + if (empty($sanitized)) { + $sanitized = 'sanitized-item'; + } + + return $sanitized; + } + + protected function displaySummary(): void + { + $this->info("\n".str_repeat('=', 60)); + $this->info('📊 CLEANUP SUMMARY'); + $this->info(str_repeat('=', 60)); + + $this->line("Records processed: {$this->totalProcessed}"); + $this->line("Records with invalid characters: {$this->totalCleaned}"); + + if ($this->option('dry-run')) { + $this->warn("\n🔍 DRY RUN - No changes were made to the database"); + $this->info('Run without --dry-run to apply these changes'); + } else { + if ($this->totalCleaned > 0) { + $this->info("\n✅ Database successfully sanitized!"); + $this->info('Changes logged to storage/logs/name-cleanup.log'); + } else { + $this->info("\n✨ No cleanup needed - all names are valid!"); + } + } + } + + protected function logChanges(): void + { + $logFile = storage_path('logs/name-cleanup.log'); + $logData = [ + 'timestamp' => now()->toISOString(), + 'total_processed' => $this->totalProcessed, + 'total_cleaned' => $this->totalCleaned, + 'changes' => $this->changes, + ]; + + file_put_contents($logFile, json_encode($logData, JSON_PRETTY_PRINT)."\n", FILE_APPEND); + + Log::info('Name Sanitization completed', [ + 'total_processed' => $this->totalProcessed, + 'total_sanitized' => $this->totalCleaned, + 'changes_count' => count($this->changes), + ]); + } + + protected function createBackup(): void + { + $this->info('💾 Creating database backup...'); + + try { + $backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql'); + + // Ensure backup directory exists + if (! file_exists(dirname($backupFile))) { + mkdir(dirname($backupFile), 0755, true); + } + + $dbConfig = config('database.connections.'.config('database.default')); + $command = sprintf( + 'pg_dump -h %s -p %s -U %s -d %s > %s', + $dbConfig['host'], + $dbConfig['port'], + $dbConfig['username'], + $dbConfig['database'], + $backupFile + ); + + exec($command, $output, $returnCode); + + if ($returnCode === 0) { + $this->info("✅ Backup created: {$backupFile}"); + } else { + $this->warn('⚠️ Backup creation may have failed. Proceeding anyway...'); + } + } catch (\Exception $e) { + $this->warn('⚠️ Could not create backup: '.$e->getMessage()); + $this->warn('Proceeding without backup...'); + } + } + + protected function truncate(string $text, int $length): string + { + return strlen($text) > $length ? substr($text, 0, $length).'...' : $text; + } +} diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index 81824675b..ce2d6d598 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Jobs\CleanupHelperContainersJob; +use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; @@ -72,7 +73,7 @@ class CleanupStuckedResources extends Command $applications = Application::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($applications as $application) { echo "Deleting stuck application: {$application->name}\n"; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); } } catch (\Throwable $e) { echo "Error in cleaning stuck application: {$e->getMessage()}\n"; @@ -82,26 +83,35 @@ class CleanupStuckedResources extends Command foreach ($applicationsPreviews as $applicationPreview) { if (! data_get($applicationPreview, 'application')) { echo "Deleting stuck application preview: {$applicationPreview->uuid}\n"; - $applicationPreview->delete(); + DeleteResourceJob::dispatch($applicationPreview); } } } catch (\Throwable $e) { echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } + try { + $applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->get(); + foreach ($applicationsPreviews as $applicationPreview) { + echo "Deleting stuck application preview: {$applicationPreview->fqdn}\n"; + DeleteResourceJob::dispatch($applicationPreview); + } + } catch (\Throwable $e) { + echo "Error in cleaning stuck application: {$e->getMessage()}\n"; + } try { $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($postgresqls as $postgresql) { echo "Deleting stuck postgresql: {$postgresql->name}\n"; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); } } catch (\Throwable $e) { echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n"; } try { - $redis = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); - foreach ($redis as $redis) { + $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); + foreach ($rediss as $redis) { echo "Deleting stuck redis: {$redis->name}\n"; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); } } catch (\Throwable $e) { echo "Error in cleaning stuck redis: {$e->getMessage()}\n"; @@ -110,7 +120,7 @@ class CleanupStuckedResources extends Command $keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($keydbs as $keydb) { echo "Deleting stuck keydb: {$keydb->name}\n"; - $keydb->forceDelete(); + DeleteResourceJob::dispatch($keydb); } } catch (\Throwable $e) { echo "Error in cleaning stuck keydb: {$e->getMessage()}\n"; @@ -119,7 +129,7 @@ class CleanupStuckedResources extends Command $dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($dragonflies as $dragonfly) { echo "Deleting stuck dragonfly: {$dragonfly->name}\n"; - $dragonfly->forceDelete(); + DeleteResourceJob::dispatch($dragonfly); } } catch (\Throwable $e) { echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n"; @@ -128,7 +138,7 @@ class CleanupStuckedResources extends Command $clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($clickhouses as $clickhouse) { echo "Deleting stuck clickhouse: {$clickhouse->name}\n"; - $clickhouse->forceDelete(); + DeleteResourceJob::dispatch($clickhouse); } } catch (\Throwable $e) { echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n"; @@ -137,7 +147,7 @@ class CleanupStuckedResources extends Command $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mongodbs as $mongodb) { echo "Deleting stuck mongodb: {$mongodb->name}\n"; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); } } catch (\Throwable $e) { echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n"; @@ -146,7 +156,7 @@ class CleanupStuckedResources extends Command $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mysqls as $mysql) { echo "Deleting stuck mysql: {$mysql->name}\n"; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); } } catch (\Throwable $e) { echo "Error in cleaning stuck mysql: {$e->getMessage()}\n"; @@ -155,7 +165,7 @@ class CleanupStuckedResources extends Command $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($mariadbs as $mariadb) { echo "Deleting stuck mariadb: {$mariadb->name}\n"; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); } } catch (\Throwable $e) { echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n"; @@ -164,7 +174,7 @@ class CleanupStuckedResources extends Command $services = Service::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($services as $service) { echo "Deleting stuck service: {$service->name}\n"; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); } } catch (\Throwable $e) { echo "Error in cleaning stuck service: {$e->getMessage()}\n"; @@ -217,19 +227,19 @@ class CleanupStuckedResources extends Command foreach ($applications as $application) { if (! data_get($application, 'environment')) { echo 'Application without environment: '.$application->name.'\n'; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); continue; } if (! $application->destination()) { echo 'Application without destination: '.$application->name.'\n'; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); continue; } if (! data_get($application, 'destination.server')) { echo 'Application without server: '.$application->name.'\n'; - $application->forceDelete(); + DeleteResourceJob::dispatch($application); continue; } @@ -242,19 +252,19 @@ class CleanupStuckedResources extends Command foreach ($postgresqls as $postgresql) { if (! data_get($postgresql, 'environment')) { echo 'Postgresql without environment: '.$postgresql->name.'\n'; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); continue; } if (! $postgresql->destination()) { echo 'Postgresql without destination: '.$postgresql->name.'\n'; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); continue; } if (! data_get($postgresql, 'destination.server')) { echo 'Postgresql without server: '.$postgresql->name.'\n'; - $postgresql->forceDelete(); + DeleteResourceJob::dispatch($postgresql); continue; } @@ -267,19 +277,19 @@ class CleanupStuckedResources extends Command foreach ($redis as $redis) { if (! data_get($redis, 'environment')) { echo 'Redis without environment: '.$redis->name.'\n'; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); continue; } if (! $redis->destination()) { echo 'Redis without destination: '.$redis->name.'\n'; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); continue; } if (! data_get($redis, 'destination.server')) { echo 'Redis without server: '.$redis->name.'\n'; - $redis->forceDelete(); + DeleteResourceJob::dispatch($redis); continue; } @@ -293,19 +303,19 @@ class CleanupStuckedResources extends Command foreach ($mongodbs as $mongodb) { if (! data_get($mongodb, 'environment')) { echo 'Mongodb without environment: '.$mongodb->name.'\n'; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); continue; } if (! $mongodb->destination()) { echo 'Mongodb without destination: '.$mongodb->name.'\n'; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); continue; } if (! data_get($mongodb, 'destination.server')) { echo 'Mongodb without server: '.$mongodb->name.'\n'; - $mongodb->forceDelete(); + DeleteResourceJob::dispatch($mongodb); continue; } @@ -319,19 +329,19 @@ class CleanupStuckedResources extends Command foreach ($mysqls as $mysql) { if (! data_get($mysql, 'environment')) { echo 'Mysql without environment: '.$mysql->name.'\n'; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); continue; } if (! $mysql->destination()) { echo 'Mysql without destination: '.$mysql->name.'\n'; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); continue; } if (! data_get($mysql, 'destination.server')) { echo 'Mysql without server: '.$mysql->name.'\n'; - $mysql->forceDelete(); + DeleteResourceJob::dispatch($mysql); continue; } @@ -345,19 +355,19 @@ class CleanupStuckedResources extends Command foreach ($mariadbs as $mariadb) { if (! data_get($mariadb, 'environment')) { echo 'Mariadb without environment: '.$mariadb->name.'\n'; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); continue; } if (! $mariadb->destination()) { echo 'Mariadb without destination: '.$mariadb->name.'\n'; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); continue; } if (! data_get($mariadb, 'destination.server')) { echo 'Mariadb without server: '.$mariadb->name.'\n'; - $mariadb->forceDelete(); + DeleteResourceJob::dispatch($mariadb); continue; } @@ -371,19 +381,19 @@ class CleanupStuckedResources extends Command foreach ($services as $service) { if (! data_get($service, 'environment')) { echo 'Service without environment: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } if (! $service->destination()) { echo 'Service without destination: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } if (! data_get($service, 'server')) { echo 'Service without server: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } @@ -396,7 +406,7 @@ class CleanupStuckedResources extends Command foreach ($serviceApplications as $service) { if (! data_get($service, 'service')) { echo 'ServiceApplication without service: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } @@ -409,7 +419,7 @@ class CleanupStuckedResources extends Command foreach ($serviceDatabases as $service) { if (! data_get($service, 'service')) { echo 'ServiceDatabase without service: '.$service->name.'\n'; - $service->forceDelete(); + DeleteResourceJob::dispatch($service); continue; } diff --git a/app/Console/Commands/CloudDeleteUser.php b/app/Console/Commands/CloudDeleteUser.php new file mode 100644 index 000000000..6928eb97b --- /dev/null +++ b/app/Console/Commands/CloudDeleteUser.php @@ -0,0 +1,722 @@ +error('This command is only available on cloud instances.'); + + return 1; + } + + $email = $this->argument('email'); + $this->isDryRun = $this->option('dry-run'); + $this->skipStripe = $this->option('skip-stripe'); + $this->skipResources = $this->option('skip-resources'); + + if ($this->isDryRun) { + $this->info('🔍 DRY RUN MODE - No data will be deleted'); + $this->newLine(); + } + + try { + $this->user = User::whereEmail($email)->firstOrFail(); + } catch (\Exception $e) { + $this->error("User with email '{$email}' not found."); + + return 1; + } + + $this->logAction("Starting user deletion process for: {$email}"); + + // Phase 1: Show User Overview (outside transaction) + if (! $this->showUserOverview()) { + $this->info('User deletion cancelled.'); + + return 0; + } + + // If not dry run, wrap everything in a transaction + if (! $this->isDryRun) { + try { + DB::beginTransaction(); + + // Phase 2: Delete Resources + if (! $this->skipResources) { + if (! $this->deleteResources()) { + DB::rollBack(); + $this->error('User deletion failed at resource deletion phase. All changes rolled back.'); + + return 1; + } + } + + // Phase 3: Delete Servers + if (! $this->deleteServers()) { + DB::rollBack(); + $this->error('User deletion failed at server deletion phase. All changes rolled back.'); + + return 1; + } + + // Phase 4: Handle Teams + if (! $this->handleTeams()) { + DB::rollBack(); + $this->error('User deletion failed at team handling phase. All changes rolled back.'); + + return 1; + } + + // Phase 5: Cancel Stripe Subscriptions + if (! $this->skipStripe && isCloud()) { + if (! $this->cancelStripeSubscriptions()) { + DB::rollBack(); + $this->error('User deletion failed at Stripe cancellation phase. All changes rolled back.'); + + return 1; + } + } + + // Phase 6: Delete User Profile + if (! $this->deleteUserProfile()) { + DB::rollBack(); + $this->error('User deletion failed at final phase. All changes rolled back.'); + + return 1; + } + + // Commit the transaction + DB::commit(); + + $this->newLine(); + $this->info('✅ User deletion completed successfully!'); + $this->logAction("User deletion completed for: {$email}"); + + } catch (\Exception $e) { + DB::rollBack(); + $this->error('An error occurred during user deletion: '.$e->getMessage()); + $this->logAction("User deletion failed for {$email}: ".$e->getMessage()); + + return 1; + } + } else { + // Dry run mode - just run through the phases without transaction + // Phase 2: Delete Resources + if (! $this->skipResources) { + if (! $this->deleteResources()) { + $this->info('User deletion would be cancelled at resource deletion phase.'); + + return 0; + } + } + + // Phase 3: Delete Servers + if (! $this->deleteServers()) { + $this->info('User deletion would be cancelled at server deletion phase.'); + + return 0; + } + + // Phase 4: Handle Teams + if (! $this->handleTeams()) { + $this->info('User deletion would be cancelled at team handling phase.'); + + return 0; + } + + // Phase 5: Cancel Stripe Subscriptions + if (! $this->skipStripe && isCloud()) { + if (! $this->cancelStripeSubscriptions()) { + $this->info('User deletion would be cancelled at Stripe cancellation phase.'); + + return 0; + } + } + + // Phase 6: Delete User Profile + if (! $this->deleteUserProfile()) { + $this->info('User deletion would be cancelled at final phase.'); + + return 0; + } + + $this->newLine(); + $this->info('✅ DRY RUN completed successfully! No data was deleted.'); + } + + return 0; + } + + private function showUserOverview(): bool + { + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 1: USER OVERVIEW'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $teams = $this->user->teams; + $ownedTeams = $teams->filter(fn ($team) => $team->pivot->role === 'owner'); + $memberTeams = $teams->filter(fn ($team) => $team->pivot->role !== 'owner'); + + // Collect all servers from all teams + $allServers = collect(); + $allApplications = collect(); + $allDatabases = collect(); + $allServices = collect(); + $activeSubscriptions = collect(); + + foreach ($teams as $team) { + $servers = $team->servers; + $allServers = $allServers->merge($servers); + + foreach ($servers as $server) { + $resources = $server->definedResources(); + foreach ($resources as $resource) { + if ($resource instanceof \App\Models\Application) { + $allApplications->push($resource); + } elseif ($resource instanceof \App\Models\Service) { + $allServices->push($resource); + } else { + $allDatabases->push($resource); + } + } + } + + if ($team->subscription && $team->subscription->stripe_subscription_id) { + $activeSubscriptions->push($team->subscription); + } + } + + $this->table( + ['Property', 'Value'], + [ + ['User', $this->user->email], + ['User ID', $this->user->id], + ['Created', $this->user->created_at->format('Y-m-d H:i:s')], + ['Last Login', $this->user->updated_at->format('Y-m-d H:i:s')], + ['Teams (Total)', $teams->count()], + ['Teams (Owner)', $ownedTeams->count()], + ['Teams (Member)', $memberTeams->count()], + ['Servers', $allServers->unique('id')->count()], + ['Applications', $allApplications->count()], + ['Databases', $allDatabases->count()], + ['Services', $allServices->count()], + ['Active Stripe Subscriptions', $activeSubscriptions->count()], + ] + ); + + $this->newLine(); + + $this->warn('⚠️ WARNING: This will permanently delete the user and all associated data!'); + $this->newLine(); + + if (! $this->confirm('Do you want to continue with the deletion process?', false)) { + return false; + } + + return true; + } + + private function deleteResources(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 2: DELETE RESOURCES'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserResources($this->user, $this->isDryRun); + $resources = $action->getResourcesPreview(); + + if ($resources['applications']->isEmpty() && + $resources['databases']->isEmpty() && + $resources['services']->isEmpty()) { + $this->info('No resources to delete.'); + + return true; + } + + $this->info('Resources to be deleted:'); + $this->newLine(); + + if ($resources['applications']->isNotEmpty()) { + $this->warn("Applications to be deleted ({$resources['applications']->count()}):"); + $this->table( + ['Name', 'UUID', 'Server', 'Status'], + $resources['applications']->map(function ($app) { + return [ + $app->name, + $app->uuid, + $app->destination->server->name, + $app->status ?? 'unknown', + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($resources['databases']->isNotEmpty()) { + $this->warn("Databases to be deleted ({$resources['databases']->count()}):"); + $this->table( + ['Name', 'Type', 'UUID', 'Server'], + $resources['databases']->map(function ($db) { + return [ + $db->name, + class_basename($db), + $db->uuid, + $db->destination->server->name, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($resources['services']->isNotEmpty()) { + $this->warn("Services to be deleted ({$resources['services']->count()}):"); + $this->table( + ['Name', 'UUID', 'Server'], + $resources['services']->map(function ($service) { + return [ + $service->name, + $service->uuid, + $service->server->name, + ]; + })->toArray() + ); + $this->newLine(); + } + + $this->error('⚠️ THIS ACTION CANNOT BE UNDONE!'); + if (! $this->confirm('Are you sure you want to delete all these resources?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting resources...'); + $result = $action->execute(); + $this->info("Deleted: {$result['applications']} applications, {$result['databases']} databases, {$result['services']} services"); + $this->logAction("Deleted resources for user {$this->user->email}: {$result['applications']} apps, {$result['databases']} databases, {$result['services']} services"); + } + + return true; + } + + private function deleteServers(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 3: DELETE SERVERS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserServers($this->user, $this->isDryRun); + $servers = $action->getServersPreview(); + + if ($servers->isEmpty()) { + $this->info('No servers to delete.'); + + return true; + } + + $this->warn("Servers to be deleted ({$servers->count()}):"); + $this->table( + ['ID', 'Name', 'IP', 'Description', 'Resources Count'], + $servers->map(function ($server) { + $resourceCount = $server->definedResources()->count(); + + return [ + $server->id, + $server->name, + $server->ip, + $server->description ?? '-', + $resourceCount, + ]; + })->toArray() + ); + $this->newLine(); + + $this->error('⚠️ WARNING: Deleting servers will remove all server configurations!'); + if (! $this->confirm('Are you sure you want to delete all these servers?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting servers...'); + $result = $action->execute(); + $this->info("Deleted {$result['servers']} servers"); + $this->logAction("Deleted {$result['servers']} servers for user {$this->user->email}"); + } + + return true; + } + + private function handleTeams(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 4: HANDLE TEAMS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new DeleteUserTeams($this->user, $this->isDryRun); + $preview = $action->getTeamsPreview(); + + // Check for edge cases first - EXIT IMMEDIATELY if found + if ($preview['edge_cases']->isNotEmpty()) { + $this->error('═══════════════════════════════════════'); + $this->error('⚠️ EDGE CASES DETECTED - CANNOT PROCEED'); + $this->error('═══════════════════════════════════════'); + $this->newLine(); + + foreach ($preview['edge_cases'] as $edgeCase) { + $team = $edgeCase['team']; + $reason = $edgeCase['reason']; + $this->error("Team: {$team->name} (ID: {$team->id})"); + $this->error("Issue: {$reason}"); + + // Show team members for context + $this->info('Current members:'); + foreach ($team->members as $member) { + $role = $member->pivot->role; + $this->line(" - {$member->name} ({$member->email}) - Role: {$role}"); + } + + // Check for active resources + $resourceCount = 0; + foreach ($team->servers as $server) { + $resources = $server->definedResources(); + $resourceCount += $resources->count(); + } + + if ($resourceCount > 0) { + $this->warn(" ⚠️ This team has {$resourceCount} active resources!"); + } + + // Show subscription details if relevant + if ($team->subscription && $team->subscription->stripe_subscription_id) { + $this->warn(' ⚠️ Active Stripe subscription details:'); + $this->warn(" Subscription ID: {$team->subscription->stripe_subscription_id}"); + $this->warn(" Customer ID: {$team->subscription->stripe_customer_id}"); + + // Show other owners who could potentially take over + $otherOwners = $team->members + ->where('id', '!=', $this->user->id) + ->filter(function ($member) { + return $member->pivot->role === 'owner'; + }); + + if ($otherOwners->isNotEmpty()) { + $this->info(' Other owners who could take over billing:'); + foreach ($otherOwners as $owner) { + $this->line(" - {$owner->name} ({$owner->email})"); + } + } + } + + $this->newLine(); + } + + $this->error('Please resolve these issues manually before retrying:'); + + // Check if any edge case involves subscription payment issues + $hasSubscriptionIssue = $preview['edge_cases']->contains(function ($edgeCase) { + return str_contains($edgeCase['reason'], 'Stripe subscription'); + }); + + if ($hasSubscriptionIssue) { + $this->info('For teams with subscription payment issues:'); + $this->info('1. Cancel the subscription through Stripe dashboard, OR'); + $this->info('2. Transfer the subscription to another owner\'s payment method, OR'); + $this->info('3. Have the other owner create a new subscription after cancelling this one'); + $this->newLine(); + } + + $hasNoOwnerReplacement = $preview['edge_cases']->contains(function ($edgeCase) { + return str_contains($edgeCase['reason'], 'No suitable owner replacement'); + }); + + if ($hasNoOwnerReplacement) { + $this->info('For teams with no suitable owner replacement:'); + $this->info('1. Assign an admin role to a trusted member, OR'); + $this->info('2. Transfer team resources to another team, OR'); + $this->info('3. Delete the team manually if no longer needed'); + $this->newLine(); + } + + $this->error('USER DELETION ABORTED DUE TO EDGE CASES'); + $this->logAction("User deletion aborted for {$this->user->email}: Edge cases in team handling"); + + // Exit immediately - don't proceed with deletion + if (! $this->isDryRun) { + DB::rollBack(); + } + exit(1); + } + + if ($preview['to_delete']->isEmpty() && + $preview['to_transfer']->isEmpty() && + $preview['to_leave']->isEmpty()) { + $this->info('No team changes needed.'); + + return true; + } + + if ($preview['to_delete']->isNotEmpty()) { + $this->warn('Teams to be DELETED (user is the only member):'); + $this->table( + ['ID', 'Name', 'Resources', 'Subscription'], + $preview['to_delete']->map(function ($team) { + $resourceCount = 0; + foreach ($team->servers as $server) { + $resourceCount += $server->definedResources()->count(); + } + $hasSubscription = $team->subscription && $team->subscription->stripe_subscription_id + ? '⚠️ YES - '.$team->subscription->stripe_subscription_id + : 'No'; + + return [ + $team->id, + $team->name, + $resourceCount, + $hasSubscription, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($preview['to_transfer']->isNotEmpty()) { + $this->warn('Teams where ownership will be TRANSFERRED:'); + $this->table( + ['Team ID', 'Team Name', 'New Owner', 'New Owner Email'], + $preview['to_transfer']->map(function ($item) { + return [ + $item['team']->id, + $item['team']->name, + $item['new_owner']->name, + $item['new_owner']->email, + ]; + })->toArray() + ); + $this->newLine(); + } + + if ($preview['to_leave']->isNotEmpty()) { + $this->warn('Teams where user will be REMOVED (other owners/admins exist):'); + $userId = $this->user->id; + $this->table( + ['ID', 'Name', 'User Role', 'Other Members'], + $preview['to_leave']->map(function ($team) use ($userId) { + $userRole = $team->members->where('id', $userId)->first()->pivot->role; + $otherMembers = $team->members->count() - 1; + + return [ + $team->id, + $team->name, + $userRole, + $otherMembers, + ]; + })->toArray() + ); + $this->newLine(); + } + + $this->error('⚠️ WARNING: Team changes affect access control and ownership!'); + if (! $this->confirm('Are you sure you want to proceed with these team changes?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Processing team changes...'); + $result = $action->execute(); + $this->info("Teams deleted: {$result['deleted']}, ownership transferred: {$result['transferred']}, left: {$result['left']}"); + $this->logAction("Team changes for user {$this->user->email}: deleted {$result['deleted']}, transferred {$result['transferred']}, left {$result['left']}"); + } + + return true; + } + + private function cancelStripeSubscriptions(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 5: CANCEL STRIPE SUBSCRIPTIONS'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $action = new CancelSubscription($this->user, $this->isDryRun); + $subscriptions = $action->getSubscriptionsPreview(); + + if ($subscriptions->isEmpty()) { + $this->info('No Stripe subscriptions to cancel.'); + + return true; + } + + $this->info('Stripe subscriptions to cancel:'); + $this->newLine(); + + $totalMonthlyValue = 0; + foreach ($subscriptions as $subscription) { + $team = $subscription->team; + $planId = $subscription->stripe_plan_id; + + // Try to get the price from config + $monthlyValue = $this->getSubscriptionMonthlyValue($planId); + $totalMonthlyValue += $monthlyValue; + + $this->line(" - {$subscription->stripe_subscription_id} (Team: {$team->name})"); + if ($monthlyValue > 0) { + $this->line(" Monthly value: \${$monthlyValue}"); + } + if ($subscription->stripe_cancel_at_period_end) { + $this->line(' ⚠️ Already set to cancel at period end'); + } + } + + if ($totalMonthlyValue > 0) { + $this->newLine(); + $this->warn("Total monthly value: \${$totalMonthlyValue}"); + } + $this->newLine(); + + $this->error('⚠️ WARNING: Subscriptions will be cancelled IMMEDIATELY (not at period end)!'); + if (! $this->confirm('Are you sure you want to cancel all these subscriptions immediately?', false)) { + return false; + } + + if (! $this->isDryRun) { + $this->info('Cancelling subscriptions...'); + $result = $action->execute(); + $this->info("Cancelled {$result['cancelled']} subscriptions, {$result['failed']} failed"); + if ($result['failed'] > 0 && ! empty($result['errors'])) { + $this->error('Failed subscriptions:'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + } + $this->logAction("Cancelled {$result['cancelled']} Stripe subscriptions for user {$this->user->email}"); + } + + return true; + } + + private function deleteUserProfile(): bool + { + $this->newLine(); + $this->info('═══════════════════════════════════════'); + $this->info('PHASE 6: DELETE USER PROFILE'); + $this->info('═══════════════════════════════════════'); + $this->newLine(); + + $this->warn('⚠️ FINAL STEP - This action is IRREVERSIBLE!'); + $this->newLine(); + + $this->info('User profile to be deleted:'); + $this->table( + ['Property', 'Value'], + [ + ['Email', $this->user->email], + ['Name', $this->user->name], + ['User ID', $this->user->id], + ['Created', $this->user->created_at->format('Y-m-d H:i:s')], + ['Email Verified', $this->user->email_verified_at ? 'Yes' : 'No'], + ['2FA Enabled', $this->user->two_factor_confirmed_at ? 'Yes' : 'No'], + ] + ); + + $this->newLine(); + + $this->warn("Type 'DELETE {$this->user->email}' to confirm final deletion:"); + $confirmation = $this->ask('Confirmation'); + + if ($confirmation !== "DELETE {$this->user->email}") { + $this->error('Confirmation text does not match. Deletion cancelled.'); + + return false; + } + + if (! $this->isDryRun) { + $this->info('Deleting user profile...'); + + try { + $this->user->delete(); + $this->info('User profile deleted successfully.'); + $this->logAction("User profile deleted: {$this->user->email}"); + } catch (\Exception $e) { + $this->error('Failed to delete user profile: '.$e->getMessage()); + $this->logAction("Failed to delete user profile {$this->user->email}: ".$e->getMessage()); + + return false; + } + } + + return true; + } + + private function getSubscriptionMonthlyValue(string $planId): int + { + // Map plan IDs to monthly values based on config + $subscriptionConfigs = config('subscription'); + + foreach ($subscriptionConfigs as $key => $value) { + if ($value === $planId && str_contains($key, 'stripe_price_id_')) { + // Extract price from key pattern: stripe_price_id_basic_monthly -> basic + $planType = str($key)->after('stripe_price_id_')->before('_')->toString(); + + // Map to known prices (you may need to adjust these based on your actual pricing) + return match ($planType) { + 'basic' => 29, + 'pro' => 49, + 'ultimate' => 99, + default => 0 + }; + } + } + + return 0; + } + + private function logAction(string $message): void + { + $logMessage = "[CloudDeleteUser] {$message}"; + + if ($this->isDryRun) { + $logMessage = "[DRY RUN] {$logMessage}"; + } + + Log::channel('single')->info($logMessage); + + // Also log to a dedicated user deletion log file + $logFile = storage_path('logs/user-deletions.log'); + $timestamp = now()->format('Y-m-d H:i:s'); + file_put_contents($logFile, "[{$timestamp}] {$logMessage}\n", FILE_APPEND | LOCK_EX); + } +} diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php index a4cfde6f8..8f26d78ff 100644 --- a/app/Console/Commands/Dev.php +++ b/app/Console/Commands/Dev.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Jobs\CheckHelperImageJob; use App\Models\InstanceSettings; use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; @@ -44,5 +45,6 @@ class Dev extends Command } else { echo "Instance already initialized.\n"; } + CheckHelperImageJob::dispatch(); } } diff --git a/app/Console/Commands/Generate/Services.php b/app/Console/Commands/Generate/Services.php index 577e94ac8..42f9360bb 100644 --- a/app/Console/Commands/Generate/Services.php +++ b/app/Console/Commands/Generate/Services.php @@ -16,7 +16,7 @@ class Services extends Command /** * {@inheritdoc} */ - protected $description = 'Generate service-templates.yaml based on /templates/compose directory'; + protected $description = 'Generates service-templates json file based on /templates/compose directory'; public function handle(): int { @@ -33,7 +33,10 @@ class Services extends Command ]; })->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesJson.PHP_EOL); + file_put_contents(base_path('templates/'.config('constants.services.file_name')), $serviceTemplatesJson.PHP_EOL); + + // Generate service-templates.json with SERVICE_URL changed to SERVICE_FQDN + $this->generateServiceTemplatesWithFqdn(); return self::SUCCESS; } @@ -71,6 +74,7 @@ class Services extends Command 'slogan' => $data->get('slogan', str($file)->headline()), 'compose' => $compose, 'tags' => $tags, + 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), ]; @@ -86,4 +90,145 @@ class Services extends Command return $payload; } + + private function generateServiceTemplatesWithFqdn(): void + { + $serviceTemplatesWithFqdn = collect(array_merge( + glob(base_path('templates/compose/*.yaml')), + glob(base_path('templates/compose/*.yml')) + )) + ->mapWithKeys(function ($file): array { + $file = basename($file); + $parsed = $this->processFileWithFqdn($file); + + return $parsed === false ? [] : [ + Arr::pull($parsed, 'name') => $parsed, + ]; + })->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + file_put_contents(base_path('templates/service-templates.json'), $serviceTemplatesWithFqdn.PHP_EOL); + + // Generate service-templates-raw.json with non-base64 encoded compose content + // $this->generateServiceTemplatesRaw(); + } + + private function processFileWithFqdn(string $file): false|array + { + $content = file_get_contents(base_path("templates/compose/$file")); + + $data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array { + preg_match('/^#(?.*):(?.*)$/U', $line, $m); + + return $m ? [trim($m['key']) => trim($m['value'])] : []; + }); + + if (str($data->get('ignore'))->toBoolean()) { + return false; + } + + $documentation = $data->get('documentation'); + $documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs'; + + // Replace SERVICE_URL with SERVICE_FQDN in the content + $modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content); + + $json = Yaml::parse($modifiedContent); + $compose = base64_encode(Yaml::dump($json, 10, 2)); + + $tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter(); + $tags = $tags->isEmpty() ? null : $tags->all(); + + $payload = [ + 'name' => pathinfo($file, PATHINFO_FILENAME), + 'documentation' => $documentation, + 'slogan' => $data->get('slogan', str($file)->headline()), + 'compose' => $compose, + 'tags' => $tags, + 'category' => $data->get('category'), + 'logo' => $data->get('logo', 'svgs/default.webp'), + 'minversion' => $data->get('minversion', '0.0.0'), + ]; + + if ($port = $data->get('port')) { + $payload['port'] = $port; + } + + if ($envFile = $data->get('env_file')) { + $envFileContent = file_get_contents(base_path("templates/compose/$envFile")); + // Also replace SERVICE_URL with SERVICE_FQDN in env file content + $modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent); + $payload['envs'] = base64_encode($modifiedEnvContent); + } + + return $payload; + } + + private function generateServiceTemplatesRaw(): void + { + $serviceTemplatesRaw = collect(array_merge( + glob(base_path('templates/compose/*.yaml')), + glob(base_path('templates/compose/*.yml')) + )) + ->mapWithKeys(function ($file): array { + $file = basename($file); + $parsed = $this->processFileWithFqdnRaw($file); + + return $parsed === false ? [] : [ + Arr::pull($parsed, 'name') => $parsed, + ]; + })->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + file_put_contents(base_path('templates/service-templates-raw.json'), $serviceTemplatesRaw.PHP_EOL); + } + + private function processFileWithFqdnRaw(string $file): false|array + { + $content = file_get_contents(base_path("templates/compose/$file")); + + $data = collect(explode(PHP_EOL, $content))->mapWithKeys(function ($line): array { + preg_match('/^#(?.*):(?.*)$/U', $line, $m); + + return $m ? [trim($m['key']) => trim($m['value'])] : []; + }); + + if (str($data->get('ignore'))->toBoolean()) { + return false; + } + + $documentation = $data->get('documentation'); + $documentation = $documentation ? $documentation.'?utm_source=coolify.io' : 'https://coolify.io/docs'; + + // Replace SERVICE_URL with SERVICE_FQDN in the content + $modifiedContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $content); + + $json = Yaml::parse($modifiedContent); + $compose = Yaml::dump($json, 10, 2); // Not base64 encoded + + $tags = str($data->get('tags'))->lower()->explode(',')->map(fn ($tag) => trim($tag))->filter(); + $tags = $tags->isEmpty() ? null : $tags->all(); + + $payload = [ + 'name' => pathinfo($file, PATHINFO_FILENAME), + 'documentation' => $documentation, + 'slogan' => $data->get('slogan', str($file)->headline()), + 'compose' => $compose, + 'tags' => $tags, + 'category' => $data->get('category'), + 'logo' => $data->get('logo', 'svgs/default.webp'), + 'minversion' => $data->get('minversion', '0.0.0'), + ]; + + if ($port = $data->get('port')) { + $payload['port'] = $port; + } + + if ($envFile = $data->get('env_file')) { + $envFileContent = file_get_contents(base_path("templates/compose/$envFile")); + // Also replace SERVICE_URL with SERVICE_FQDN in env file content (not base64 encoded) + $modifiedEnvContent = str_replace('SERVICE_URL', 'SERVICE_FQDN', $envFileContent); + $payload['envs'] = $modifiedEnvContent; + } + + return $payload; + } } diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 1a7c0911f..6e8d18f61 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -5,8 +5,10 @@ namespace App\Console\Commands; use App\Enums\ActivityTypes; use App\Enums\ApplicationDeploymentStatus; use App\Jobs\CheckHelperImageJob; +use App\Jobs\PullChangelog; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; +use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandalonePostgresql; @@ -18,27 +20,49 @@ use Illuminate\Support\Facades\Http; class Init extends Command { - protected $signature = 'app:init {--force-cloud}'; + protected $signature = 'app:init'; protected $description = 'Cleanup instance related stuffs'; public $servers = null; + public InstanceSettings $settings; + public function handle() { - $this->optimize(); + Artisan::call('optimize:clear'); + Artisan::call('optimize'); - if (isCloud() && ! $this->option('force-cloud')) { - echo "Skipping init as we are on cloud and --force-cloud option is not set\n"; + try { + $this->pullTemplatesFromCDN(); + } catch (\Throwable $e) { + echo "Could not pull templates from CDN: {$e->getMessage()}\n"; + } + try { + $this->pullChangelogFromGitHub(); + } catch (\Throwable $e) { + echo "Could not changelogs from github: {$e->getMessage()}\n"; + } + + try { + $this->pullHelperImage(); + } catch (\Throwable $e) { + echo "Error in pullHelperImage command: {$e->getMessage()}\n"; + } + + if (isCloud()) { return; } + $this->settings = instanceSettings(); $this->servers = Server::all(); - if (! isCloud()) { + + $do_not_track = data_get($this->settings, 'do_not_track', true); + if ($do_not_track == false) { $this->sendAliveSignal(); - get_public_ips(); } + get_public_ips(); // Backward compatibility $this->replaceSlashInEnvironmentName(); @@ -46,51 +70,54 @@ class Init extends Command $this->updateUserEmails(); // $this->updateTraefikLabels(); - if (! isCloud() || $this->option('force-cloud')) { - $this->cleanupUnusedNetworkFromCoolifyProxy(); - } - - $this->call('cleanup:redis'); - - $this->call('cleanup:stucked-resources'); + $this->cleanupUnusedNetworkFromCoolifyProxy(); try { - $this->pullHelperImage(); + $this->call('cleanup:redis'); } catch (\Throwable $e) { - // + echo "Error in cleanup:redis command: {$e->getMessage()}\n"; } + try { + $this->call('cleanup:names'); + } catch (\Throwable $e) { + echo "Error in cleanup:names command: {$e->getMessage()}\n"; + } + try { + $this->call('cleanup:stucked-resources'); + } catch (\Throwable $e) { + echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n"; + } + try { + $updatedCount = ApplicationDeploymentQueue::whereIn('status', [ + ApplicationDeploymentStatus::IN_PROGRESS->value, + ApplicationDeploymentStatus::QUEUED->value, + ])->update([ + 'status' => ApplicationDeploymentStatus::FAILED->value, + ]); - if (isCloud()) { - try { - $this->cleanupUnnecessaryDynamicProxyConfiguration(); - $this->pullTemplatesFromCDN(); - } catch (\Throwable $e) { - echo "Could not pull templates from CDN: {$e->getMessage()}\n"; + if ($updatedCount > 0) { + echo "Marked {$updatedCount} stuck deployments as failed\n"; } - - return; - } - - try { - $this->cleanupInProgressApplicationDeployments(); - $this->pullTemplatesFromCDN(); } catch (\Throwable $e) { - echo "Could not pull templates from CDN: {$e->getMessage()}\n"; + echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n"; } + try { $localhost = $this->servers->where('id', 0)->first(); - $localhost->setupDynamicProxyConfiguration(); + if ($localhost) { + $localhost->setupDynamicProxyConfiguration(); + } } catch (\Throwable $e) { echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; } - $settings = instanceSettings(); + if (! is_null(config('constants.coolify.autoupdate', null))) { if (config('constants.coolify.autoupdate') == true) { echo "Enabling auto-update\n"; - $settings->update(['is_auto_update_enabled' => true]); + $this->settings->update(['is_auto_update_enabled' => true]); } else { echo "Disabling auto-update\n"; - $settings->update(['is_auto_update_enabled' => false]); + $this->settings->update(['is_auto_update_enabled' => false]); } } } @@ -105,21 +132,25 @@ class Init extends Command $response = Http::retry(3, 1000)->get(config('constants.services.official')); if ($response->successful()) { $services = $response->json(); - File::put(base_path('templates/service-templates.json'), json_encode($services)); + File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services)); } } - private function optimize() + private function pullChangelogFromGitHub() { - Artisan::call('optimize:clear'); - Artisan::call('optimize'); + try { + PullChangelog::dispatch(); + echo "Changelog fetch initiated\n"; + } catch (\Throwable $e) { + echo "Could not fetch changelog from GitHub: {$e->getMessage()}\n"; + } } private function updateUserEmails() { try { User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) { - $user->update(['email' => strtolower($user->email)]); + $user->update(['email' => $user->email]); }); } catch (\Throwable $e) { echo "Error in updating user emails: {$e->getMessage()}\n"; @@ -135,27 +166,6 @@ class Init extends Command } } - private function cleanupUnnecessaryDynamicProxyConfiguration() - { - foreach ($this->servers as $server) { - try { - if (! $server->isFunctional()) { - continue; - } - if ($server->id === 0) { - continue; - } - $file = $server->proxyPath().'/dynamic/coolify.yaml'; - - return instant_remote_process([ - "rm -f $file", - ], $server, false); - } catch (\Throwable $e) { - echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n"; - } - } - } - private function cleanupUnusedNetworkFromCoolifyProxy() { foreach ($this->servers as $server) { @@ -225,13 +235,6 @@ class Init extends Command { $id = config('app.id'); $version = config('constants.coolify.version'); - $settings = instanceSettings(); - $do_not_track = data_get($settings, 'do_not_track'); - if ($do_not_track == true) { - echo "Do_not_track is enabled\n"; - - return; - } try { Http::get("https://undead.coolify.io/v4/alive?appId=$id&version=$version"); } catch (\Throwable $e) { @@ -239,23 +242,6 @@ class Init extends Command } } - private function cleanupInProgressApplicationDeployments() - { - // Cleanup any failed deployments - try { - if (isCloud()) { - return; - } - $queued_inprogress_deployments = ApplicationDeploymentQueue::whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])->get(); - foreach ($queued_inprogress_deployments as $deployment) { - $deployment->status = ApplicationDeploymentStatus::FAILED->value; - $deployment->save(); - } - } catch (\Throwable $e) { - echo "Error: {$e->getMessage()}\n"; - } - } - private function replaceSlashInEnvironmentName() { if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) { diff --git a/app/Console/Commands/ServicesDelete.php b/app/Console/Commands/ServicesDelete.php index b5a74166a..870cef3d9 100644 --- a/app/Console/Commands/ServicesDelete.php +++ b/app/Console/Commands/ServicesDelete.php @@ -6,7 +6,14 @@ use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\Server; use App\Models\Service; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use Illuminate\Console\Command; use function Laravel\Prompts\confirm; @@ -103,19 +110,79 @@ class ServicesDelete extends Command private function deleteDatabase() { - $databases = StandalonePostgresql::all(); - if ($databases->count() === 0) { + // Collect all databases from all types with unique identifiers + $allDatabases = collect(); + $databaseOptions = collect(); + + // Add PostgreSQL databases + foreach (StandalonePostgresql::all() as $db) { + $key = "postgresql_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (PostgreSQL)"); + } + + // Add MySQL databases + foreach (StandaloneMysql::all() as $db) { + $key = "mysql_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (MySQL)"); + } + + // Add MariaDB databases + foreach (StandaloneMariadb::all() as $db) { + $key = "mariadb_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (MariaDB)"); + } + + // Add MongoDB databases + foreach (StandaloneMongodb::all() as $db) { + $key = "mongodb_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (MongoDB)"); + } + + // Add Redis databases + foreach (StandaloneRedis::all() as $db) { + $key = "redis_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (Redis)"); + } + + // Add KeyDB databases + foreach (StandaloneKeydb::all() as $db) { + $key = "keydb_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (KeyDB)"); + } + + // Add Dragonfly databases + foreach (StandaloneDragonfly::all() as $db) { + $key = "dragonfly_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (Dragonfly)"); + } + + // Add ClickHouse databases + foreach (StandaloneClickhouse::all() as $db) { + $key = "clickhouse_{$db->id}"; + $allDatabases->put($key, $db); + $databaseOptions->put($key, "{$db->name} (ClickHouse)"); + } + + if ($allDatabases->count() === 0) { $this->error('There are no databases to delete.'); return; } + $databasesToDelete = multiselect( 'What database do you want to delete?', - $databases->pluck('name', 'id')->sortKeys(), + $databaseOptions->sortKeys(), ); - foreach ($databasesToDelete as $database) { - $toDelete = $databases->where('id', $database)->first(); + foreach ($databasesToDelete as $databaseKey) { + $toDelete = $allDatabases->get($databaseKey); if ($toDelete) { $this->info($toDelete); $confirmed = confirm('Are you sure you want to delete all selected resources?'); diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php index df1903828..b0cd24715 100644 --- a/app/Console/Commands/SyncBunny.php +++ b/app/Console/Commands/SyncBunny.php @@ -16,7 +16,7 @@ class SyncBunny extends Command * * @var string */ - protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; + protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}'; /** * The console command description. @@ -25,6 +25,50 @@ class SyncBunny extends Command */ protected $description = 'Sync files to BunnyCDN'; + /** + * Fetch GitHub releases and sync to CDN + */ + private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn) + { + $this->info('Fetching releases from GitHub...'); + try { + $response = Http::timeout(30) + ->get('https://api.github.com/repos/coollabsio/coolify/releases', [ + 'per_page' => 30, // Fetch more releases for better changelog + ]); + + if ($response->successful()) { + $releases = $response->json(); + + // Save releases to a temporary file + $releases_file = "$parent_dir/releases.json"; + file_put_contents($releases_file, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + // Upload to CDN + Http::pool(fn (Pool $pool) => [ + $pool->storage(fileName: $releases_file)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/releases.json"), + $pool->purge("$bunny_cdn/coolify/releases.json"), + ]); + + // Clean up temporary file + unlink($releases_file); + + $this->info('releases.json uploaded & purged...'); + $this->info('Total releases synced: '.count($releases)); + + return true; + } else { + $this->error('Failed to fetch releases from GitHub: '.$response->status()); + + return false; + } + } catch (\Throwable $e) { + $this->error('Error fetching releases: '.$e->getMessage()); + + return false; + } + } + /** * Execute the console command. */ @@ -33,6 +77,7 @@ class SyncBunny extends Command $that = $this; $only_template = $this->option('templates'); $only_version = $this->option('release'); + $only_github_releases = $this->option('github-releases'); $nightly = $this->option('nightly'); $bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn_path = 'coolify'; @@ -45,7 +90,7 @@ class SyncBunny extends Command $install_script = 'install.sh'; $upgrade_script = 'upgrade.sh'; $production_env = '.env.production'; - $service_template = 'service-templates.json'; + $service_template = config('constants.services.file_name'); $versions = 'versions.json'; $compose_file_location = "$parent_dir/$compose_file"; @@ -90,7 +135,7 @@ class SyncBunny extends Command $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - if (! $only_template && ! $only_version) { + if (! $only_template && ! $only_version && ! $only_github_releases) { if ($nightly) { $this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.'); } else { @@ -102,7 +147,7 @@ class SyncBunny extends Command } } if ($only_template) { - $this->info('About to sync service-templates.json to BunnyCDN.'); + $this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.'); $confirmed = confirm('Are you sure you want to sync?'); if (! $confirmed) { return; @@ -128,12 +173,29 @@ class SyncBunny extends Command if (! $confirmed) { return; } + + // First sync GitHub releases + $this->info('Syncing GitHub releases first...'); + $this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn); + + // Then sync versions.json Http::pool(fn (Pool $pool) => [ $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), ]); $this->info('versions.json uploaded & purged...'); + return; + } elseif ($only_github_releases) { + $this->info('About to sync GitHub releases to BunnyCDN.'); + $confirmed = confirm('Are you sure you want to sync GitHub releases?'); + if (! $confirmed) { + return; + } + + // Use the reusable function + $this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn); + return; } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index eda2fca74..c2ea27274 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -6,10 +6,11 @@ use App\Jobs\CheckAndStartSentinelJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CleanupInstanceStuffsJob; +use App\Jobs\PullChangelog; use App\Jobs\PullTemplatesFromCDN; use App\Jobs\RegenerateSslCertJob; use App\Jobs\ScheduledJobManager; -use App\Jobs\ServerResourceManager; +use App\Jobs\ServerManagerJob; use App\Jobs\UpdateCoolifyJob; use App\Models\InstanceSettings; use App\Models\Server; @@ -54,7 +55,7 @@ class Kernel extends ConsoleKernel $this->scheduleInstance->job(new CheckHelperImageJob)->everyTenMinutes()->onOneServer(); // Server Jobs - $this->scheduleInstance->job(new ServerResourceManager)->everyMinute()->onOneServer(); + $this->scheduleInstance->job(new ServerManagerJob)->everyMinute()->onOneServer(); // Scheduled Jobs (Backups & Tasks) $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer(); @@ -67,12 +68,13 @@ class Kernel extends ConsoleKernel $this->scheduleInstance->command('cleanup:unreachable-servers')->daily()->onOneServer(); $this->scheduleInstance->job(new PullTemplatesFromCDN)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer(); + $this->scheduleInstance->job(new PullChangelog)->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer(); $this->scheduleInstance->job(new CleanupInstanceStuffsJob)->everyTwoMinutes()->onOneServer(); $this->scheduleUpdates(); // Server Jobs - $this->scheduleInstance->job(new ServerResourceManager)->everyMinute()->onOneServer(); + $this->scheduleInstance->job(new ServerManagerJob)->everyMinute()->onOneServer(); $this->pullImages(); diff --git a/app/Events/ApplicationConfigurationChanged.php b/app/Events/ApplicationConfigurationChanged.php new file mode 100644 index 000000000..3dd532b19 --- /dev/null +++ b/app/Events/ApplicationConfigurationChanged.php @@ -0,0 +1,35 @@ +check() && auth()->user()->currentTeam()) { + $teamId = auth()->user()->currentTeam()->id; + } + $this->teamId = $teamId; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Events/SentinelRestarted.php b/app/Events/SentinelRestarted.php new file mode 100644 index 000000000..9ddc3a07f --- /dev/null +++ b/app/Events/SentinelRestarted.php @@ -0,0 +1,39 @@ +teamId = $server->team_id; + $this->serverUuid = $server->uuid; + $this->version = $version; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 8c89bb07f..3d731223d 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -29,6 +29,7 @@ class Handler extends ExceptionHandler */ protected $dontReport = [ ProcessException::class, + NonReportableException::class, ]; /** @@ -53,6 +54,35 @@ class Handler extends ExceptionHandler return redirect()->guest($exception->redirectTo($request) ?? route('login')); } + /** + * Render an exception into an HTTP response. + */ + public function render($request, Throwable $e) + { + // Handle authorization exceptions for API routes + if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($request->is('api/*') || $request->expectsJson()) { + // Get the custom message from the policy if available + $message = $e->getMessage(); + + // Clean up the message for API responses (remove HTML tags if present) + $message = strip_tags(str_replace('
', ' ', $message)); + + // If no custom message, use a default one + if (empty($message) || $message === 'This action is unauthorized.') { + $message = 'You are not authorized to perform this action.'; + } + + return response()->json([ + 'message' => $message, + 'error' => 'Unauthorized', + ], 403); + } + } + + return parent::render($request, $e); + } + /** * Register the exception handling callbacks for the application. */ @@ -81,9 +111,14 @@ class Handler extends ExceptionHandler ); } ); + // Check for errors that should not be reported to Sentry if (str($e->getMessage())->contains('No space left on device')) { + // Log locally but don't send to Sentry + logger()->warning('Disk space error: '.$e->getMessage()); + return; } + Integration::captureUnhandledException($e); }); } diff --git a/app/Exceptions/NonReportableException.php b/app/Exceptions/NonReportableException.php new file mode 100644 index 000000000..4c4672127 --- /dev/null +++ b/app/Exceptions/NonReportableException.php @@ -0,0 +1,31 @@ +getMessage(), $exception->getCode(), $exception); + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 8caa2880a..f847f33cc 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -4,7 +4,9 @@ namespace App\Helpers; use App\Models\PrivateKey; use App\Models\Server; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; class SshMultiplexingHelper @@ -30,6 +32,7 @@ class SshMultiplexingHelper $sshConfig = self::serverSshConfiguration($server); $muxSocket = $sshConfig['muxFilename']; + // Check if connection exists $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; if (data_get($server, 'settings.is_cloudflare_tunnel')) { $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; @@ -41,6 +44,24 @@ class SshMultiplexingHelper return self::establishNewMultiplexedConnection($server); } + // Connection exists, ensure we have metadata for age tracking + if (self::getConnectionAge($server) === null) { + // Existing connection but no metadata, store current time as fallback + self::storeConnectionMetadata($server); + } + + // Connection exists, check if it needs refresh due to age + if (self::isConnectionExpired($server)) { + return self::refreshMultiplexedConnection($server); + } + + // Perform health check if enabled + if (config('constants.ssh.mux_health_check_enabled')) { + if (! self::isConnectionHealthy($server)) { + return self::refreshMultiplexedConnection($server); + } + } + return true; } @@ -65,6 +86,9 @@ class SshMultiplexingHelper return false; } + // Store connection metadata for tracking + self::storeConnectionMetadata($server); + return true; } @@ -79,6 +103,9 @@ class SshMultiplexingHelper } $closeCommand .= "{$server->user}@{$server->ip}"; Process::run($closeCommand); + + // Clear connection metadata from cache + self::clearConnectionMetadata($server); } public static function generateScpCommand(Server $server, string $source, string $dest) @@ -94,8 +121,18 @@ class SshMultiplexingHelper if ($server->isIpv6()) { $scp_command .= '-6 '; } - if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) { - $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + if (self::isMultiplexingEnabled()) { + try { + if (self::ensureMultiplexedConnection($server)) { + $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + } + } catch (\Exception $e) { + Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [ + 'server' => $server->name ?? $server->ip, + 'error' => $e->getMessage(), + ]); + // Continue without multiplexing + } } if (data_get($server, 'settings.is_cloudflare_tunnel')) { @@ -130,8 +167,16 @@ class SshMultiplexingHelper $ssh_command = "timeout $timeout ssh "; - if (self::isMultiplexingEnabled() && self::ensureMultiplexedConnection($server)) { - $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + $multiplexingSuccessful = false; + if (self::isMultiplexingEnabled()) { + try { + $multiplexingSuccessful = self::ensureMultiplexedConnection($server); + if ($multiplexingSuccessful) { + $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + } + } catch (\Exception $e) { + // Continue without multiplexing + } } if (data_get($server, 'settings.is_cloudflare_tunnel')) { @@ -186,4 +231,81 @@ class SshMultiplexingHelper return $options; } + + /** + * Check if the multiplexed connection is healthy by running a test command + */ + public static function isConnectionHealthy(Server $server): bool + { + $sshConfig = self::serverSshConfiguration($server); + $muxSocket = $sshConfig['muxFilename']; + $healthCheckTimeout = config('constants.ssh.mux_health_check_timeout'); + + $healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket "; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + $healthCommand .= "{$server->user}@{$server->ip} 'echo \"health_check_ok\"'"; + + $process = Process::run($healthCommand); + $isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); + + return $isHealthy; + } + + /** + * Check if the connection has exceeded its maximum age + */ + public static function isConnectionExpired(Server $server): bool + { + $connectionAge = self::getConnectionAge($server); + $maxAge = config('constants.ssh.mux_max_age'); + + return $connectionAge !== null && $connectionAge > $maxAge; + } + + /** + * Get the age of the current connection in seconds + */ + public static function getConnectionAge(Server $server): ?int + { + $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; + $connectionTime = Cache::get($cacheKey); + + if ($connectionTime === null) { + return null; + } + + return time() - $connectionTime; + } + + /** + * Refresh a multiplexed connection by closing and re-establishing it + */ + public static function refreshMultiplexedConnection(Server $server): bool + { + // Close existing connection + self::removeMuxFile($server); + + // Establish new connection + return self::establishNewMultiplexedConnection($server); + } + + /** + * Store connection metadata when a new connection is established + */ + private static function storeConnectionMetadata(Server $server): void + { + $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; + Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time + } + + /** + * Clear connection metadata from cache + */ + private static function clearConnectionMetadata(Server $server): void + { + $cacheKey = "ssh_mux_connection_time_{$server->uuid}"; + Cache::forget($cacheKey); + } } diff --git a/app/Helpers/SshRetryHandler.php b/app/Helpers/SshRetryHandler.php new file mode 100644 index 000000000..aaaf4252a --- /dev/null +++ b/app/Helpers/SshRetryHandler.php @@ -0,0 +1,34 @@ +executeWithSshRetry($callback, $context, $throwError); + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 0860c7133..cd640df17 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -15,6 +15,8 @@ use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use App\Models\Service; +use App\Rules\ValidGitBranch; +use App\Rules\ValidGitRepositoryUrl; use Illuminate\Http\Request; use Illuminate\Validation\Rule; use OpenApi\Attributes as OA; @@ -188,6 +190,7 @@ class ApplicationsController extends Controller 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -215,6 +218,35 @@ class ApplicationsController extends Controller response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_public_application(Request $request) @@ -308,6 +340,7 @@ class ApplicationsController extends Controller 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -335,6 +368,35 @@ class ApplicationsController extends Controller response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_private_gh_app_application(Request $request) @@ -428,6 +490,7 @@ class ApplicationsController extends Controller 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -455,6 +518,35 @@ class ApplicationsController extends Controller response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_private_deploy_key_application(Request $request) @@ -532,6 +624,7 @@ class ApplicationsController extends Controller 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -559,6 +652,35 @@ class ApplicationsController extends Controller response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_dockerfile_application(Request $request) @@ -633,6 +755,7 @@ class ApplicationsController extends Controller 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -660,6 +783,35 @@ class ApplicationsController extends Controller response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_dockerimage_application(Request $request) @@ -697,6 +849,7 @@ class ApplicationsController extends Controller 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -724,6 +877,35 @@ class ApplicationsController extends Controller response: 400, ref: '#/components/responses/400', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function create_dockercompose_application(Request $request) @@ -738,11 +920,13 @@ class ApplicationsController extends Controller return invalidTokenResponse(); } + $this->authorize('create', Application::class); + $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override']; $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', @@ -831,8 +1015,8 @@ class ApplicationsController extends Controller $destination = $destinations->first(); if ($type === 'public') { $validationRules = [ - 'git_repository' => 'string|required', - 'git_branch' => 'string|required', + 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], + 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'docker_compose_location' => 'string', @@ -883,7 +1067,7 @@ class ApplicationsController extends Controller $application->source_type = GithubApp::class; $application->source_id = GithubApp::find(0)->id; } - $application->git_repository = $repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2); + $application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString(); $application->fqdn = $fqdn; $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); @@ -935,7 +1119,7 @@ class ApplicationsController extends Controller } elseif ($type === 'private-gh-app') { $validationRules = [ 'git_repository' => 'string|required', - 'git_branch' => 'string|required', + 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'github_app_uuid' => 'string|required', @@ -1043,7 +1227,7 @@ class ApplicationsController extends Controller $application->docker_compose_domains = $dockerComposeDomainsJson; } $application->fqdn = $fqdn; - $application->git_repository = $gitRepository; + $application->git_repository = str($gitRepository)->trim()->toString(); $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; @@ -1090,8 +1274,8 @@ class ApplicationsController extends Controller } elseif ($type === 'private-deploy-key') { $validationRules = [ - 'git_repository' => 'string|required', - 'git_branch' => 'string|required', + 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], + 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required', 'private_key_uuid' => 'string|required', @@ -1376,7 +1560,7 @@ class ApplicationsController extends Controller 'domains' => data_get($application, 'domains'), ]))->setStatusCode(201); } elseif ($type === 'dockercompose') { - $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw']; + $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override']; $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -1519,6 +1703,8 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Application not found.'], 404); } + $this->authorize('view', $application); + return response()->json($this->removeSensitiveData($application)); } @@ -1697,12 +1883,14 @@ class ApplicationsController extends Controller ], 404); } + $this->authorize('delete', $application); + DeleteResourceJob::dispatch( resource: $application, - deleteConfigurations: $request->query->get('delete_configurations', true), deleteVolumes: $request->query->get('delete_volumes', true), - dockerCleanup: $request->query->get('docker_cleanup', true), - deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) + deleteConnectedNetworks: $request->query->get('delete_connected_networks', true), + deleteConfigurations: $request->query->get('delete_configurations', true), + dockerCleanup: $request->query->get('docker_cleanup', true) ); return response()->json([ @@ -1802,6 +1990,7 @@ class ApplicationsController extends Controller 'watch_paths' => ['type' => 'string', 'description' => 'The watch paths.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], ], ) ), @@ -1835,6 +2024,35 @@ class ApplicationsController extends Controller response: 404, ref: '#/components/responses/404', ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), ] )] public function update_by_uuid(Request $request) @@ -1854,8 +2072,11 @@ class ApplicationsController extends Controller 'message' => 'Application not found', ], 404); } + + $this->authorize('update', $application); + $server = $application->destination->server; - $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network']; + $allowedFields = ['name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override']; $validationRules = [ 'name' => 'string|max:255', @@ -1971,14 +2192,23 @@ class ApplicationsController extends Controller 'errors' => $errors, ], 422); } - if (checkIfDomainIsAlreadyUsed($fqdn, $teamId, $uuid)) { + // Check for domain conflicts + $result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid); + if (isset($result['error'])) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => [ - 'domains' => 'One of the domain is already used.', - ], + 'errors' => ['domains' => $result['error']], ], 422); } + + // If there are conflicts and force is not enabled, return warning + if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } } $dockerComposeDomainsJson = collect(); @@ -2054,6 +2284,9 @@ class ApplicationsController extends Controller data_set($data, 'docker_compose_domains', json_encode($dockerComposeDomainsJson)); } $application->fill($data); + if ($application->settings->is_container_label_readonly_enabled && $requestHasDomains && $server->isProxyShouldRun()) { + $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); + } $application->save(); if ($instantDeploy) { @@ -2138,6 +2371,9 @@ class ApplicationsController extends Controller 'message' => 'Application not found', ], 404); } + + $this->authorize('view', $application); + $envs = $application->environment_variables->sortBy('id')->merge($application->environment_variables_preview->sortBy('id')); $envs = $envs->map(function ($env) { @@ -2193,7 +2429,6 @@ class ApplicationsController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -2234,7 +2469,7 @@ class ApplicationsController extends Controller )] public function update_env_by_uuid(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -2252,11 +2487,13 @@ class ApplicationsController extends Controller 'message' => 'Application not found', ], 404); } + + $this->authorize('manageEnvironment', $application); + $validator = customApiValidator($request->all(), [ 'key' => 'string|required', 'value' => 'string|nullable', 'is_preview' => 'boolean', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -2277,16 +2514,12 @@ class ApplicationsController extends Controller ], 422); } $is_preview = $request->is_preview ?? false; - $is_build_time = $request->is_build_time ?? false; $is_literal = $request->is_literal ?? false; $key = str($request->key)->trim()->replace(' ', '_')->value; if ($is_preview) { $env = $application->environment_variables_preview->where('key', $key)->first(); if ($env) { $env->value = $request->value; - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2299,6 +2532,12 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $request->is_shown_once) { $env->is_shown_once = $request->is_shown_once; } + if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) { + $env->is_runtime = $request->is_runtime; + } + if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { + $env->is_buildtime = $request->is_buildtime; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -2311,9 +2550,6 @@ class ApplicationsController extends Controller $env = $application->environment_variables->where('key', $key)->first(); if ($env) { $env->value = $request->value; - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2326,6 +2562,12 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $request->is_shown_once) { $env->is_shown_once = $request->is_shown_once; } + if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) { + $env->is_runtime = $request->is_runtime; + } + if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { + $env->is_buildtime = $request->is_buildtime; + } $env->save(); return response()->json($this->removeSensitiveData($env))->setStatusCode(201); @@ -2380,7 +2622,6 @@ class ApplicationsController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -2442,6 +2683,8 @@ class ApplicationsController extends Controller ], 404); } + $this->authorize('manageEnvironment', $application); + $bulk_data = $request->get('data'); if (! $bulk_data) { return response()->json([ @@ -2449,7 +2692,7 @@ class ApplicationsController extends Controller ], 400); } $bulk_data = collect($bulk_data)->map(function ($item) { - return collect($item)->only(['key', 'value', 'is_preview', 'is_build_time', 'is_literal']); + return collect($item)->only(['key', 'value', 'is_preview', 'is_literal']); }); $returnedEnvs = collect(); foreach ($bulk_data as $item) { @@ -2457,7 +2700,6 @@ class ApplicationsController extends Controller 'key' => 'string|required', 'value' => 'string|nullable', 'is_preview' => 'boolean', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -2469,7 +2711,6 @@ class ApplicationsController extends Controller ], 422); } $is_preview = $item->get('is_preview') ?? false; - $is_build_time = $item->get('is_build_time') ?? false; $is_literal = $item->get('is_literal') ?? false; $is_multi_line = $item->get('is_multiline') ?? false; $is_shown_once = $item->get('is_shown_once') ?? false; @@ -2478,9 +2719,7 @@ class ApplicationsController extends Controller $env = $application->environment_variables_preview->where('key', $key)->first(); if ($env) { $env->value = $item->get('value'); - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } + if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2490,16 +2729,23 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $item->get('is_shown_once')) { $env->is_shown_once = $item->get('is_shown_once'); } + if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) { + $env->is_runtime = $item->get('is_runtime'); + } + if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { + $env->is_buildtime = $item->get('is_buildtime'); + } $env->save(); } else { $env = $application->environment_variables()->create([ 'key' => $item->get('key'), 'value' => $item->get('value'), 'is_preview' => $is_preview, - 'is_build_time' => $is_build_time, 'is_literal' => $is_literal, 'is_multiline' => $is_multi_line, 'is_shown_once' => $is_shown_once, + 'is_runtime' => $item->get('is_runtime', true), + 'is_buildtime' => $item->get('is_buildtime', true), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2508,9 +2754,6 @@ class ApplicationsController extends Controller $env = $application->environment_variables->where('key', $key)->first(); if ($env) { $env->value = $item->get('value'); - if ($env->is_build_time != $is_build_time) { - $env->is_build_time = $is_build_time; - } if ($env->is_literal != $is_literal) { $env->is_literal = $is_literal; } @@ -2520,16 +2763,23 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $item->get('is_shown_once')) { $env->is_shown_once = $item->get('is_shown_once'); } + if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) { + $env->is_runtime = $item->get('is_runtime'); + } + if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { + $env->is_buildtime = $item->get('is_buildtime'); + } $env->save(); } else { $env = $application->environment_variables()->create([ 'key' => $item->get('key'), 'value' => $item->get('value'), 'is_preview' => $is_preview, - 'is_build_time' => $is_build_time, 'is_literal' => $is_literal, 'is_multiline' => $is_multi_line, 'is_shown_once' => $is_shown_once, + 'is_runtime' => $item->get('is_runtime', true), + 'is_buildtime' => $item->get('is_buildtime', true), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2573,7 +2823,6 @@ class ApplicationsController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -2613,7 +2862,7 @@ class ApplicationsController extends Controller )] public function create_env(Request $request) { - $allowedFields = ['key', 'value', 'is_preview', 'is_build_time', 'is_literal']; + $allowedFields = ['key', 'value', 'is_preview', 'is_literal']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -2626,11 +2875,13 @@ class ApplicationsController extends Controller 'message' => 'Application not found', ], 404); } + + $this->authorize('manageEnvironment', $application); + $validator = customApiValidator($request->all(), [ 'key' => 'string|required', 'value' => 'string|nullable', 'is_preview' => 'boolean', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -2664,10 +2915,11 @@ class ApplicationsController extends Controller 'key' => $request->key, 'value' => $request->value, 'is_preview' => $request->is_preview ?? false, - 'is_build_time' => $request->is_build_time ?? false, 'is_literal' => $request->is_literal ?? false, 'is_multiline' => $request->is_multiline ?? false, 'is_shown_once' => $request->is_shown_once ?? false, + 'is_runtime' => $request->is_runtime ?? true, + 'is_buildtime' => $request->is_buildtime ?? true, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2687,10 +2939,11 @@ class ApplicationsController extends Controller 'key' => $request->key, 'value' => $request->value, 'is_preview' => $request->is_preview ?? false, - 'is_build_time' => $request->is_build_time ?? false, 'is_literal' => $request->is_literal ?? false, 'is_multiline' => $request->is_multiline ?? false, 'is_shown_once' => $request->is_shown_once ?? false, + 'is_runtime' => $request->is_runtime ?? true, + 'is_buildtime' => $request->is_buildtime ?? true, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2776,6 +3029,9 @@ class ApplicationsController extends Controller 'message' => 'Application not found.', ], 404); } + + $this->authorize('manageEnvironment', $application); + $found_env = EnvironmentVariable::where('uuid', $request->env_uuid) ->where('resourceable_type', Application::class) ->where('resourceable_id', $application->id) @@ -2879,6 +3135,8 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Application not found.'], 404); } + $this->authorize('deploy', $application); + $deployment_uuid = new Cuid2; $result = queue_application_deployment( @@ -2971,6 +3229,9 @@ class ApplicationsController extends Controller if (! $application) { return response()->json(['message' => 'Application not found.'], 404); } + + $this->authorize('deploy', $application); + StopApplication::dispatch($application); return response()->json( @@ -3048,6 +3309,8 @@ class ApplicationsController extends Controller return response()->json(['message' => 'Application not found.'], 404); } + $this->authorize('deploy', $application); + $deployment_uuid = new Cuid2; $result = queue_application_deployment( @@ -3070,131 +3333,6 @@ class ApplicationsController extends Controller ); } - // #[OA\Post( - // summary: 'Execute Command', - // description: "Execute a command on the application's current container.", - // path: '/applications/{uuid}/execute', - // operationId: 'execute-command-application', - // security: [ - // ['bearerAuth' => []], - // ], - // tags: ['Applications'], - // parameters: [ - // new OA\Parameter( - // name: 'uuid', - // in: 'path', - // description: 'UUID of the application.', - // required: true, - // schema: new OA\Schema( - // type: 'string', - // format: 'uuid', - // ) - // ), - // ], - // requestBody: new OA\RequestBody( - // required: true, - // description: 'Command to execute.', - // content: new OA\MediaType( - // mediaType: 'application/json', - // schema: new OA\Schema( - // type: 'object', - // properties: [ - // 'command' => ['type' => 'string', 'description' => 'Command to execute.'], - // ], - // ), - // ), - // ), - // responses: [ - // new OA\Response( - // response: 200, - // description: "Execute a command on the application's current container.", - // content: [ - // new OA\MediaType( - // mediaType: 'application/json', - // schema: new OA\Schema( - // type: 'object', - // properties: [ - // 'message' => ['type' => 'string', 'example' => 'Command executed.'], - // 'response' => ['type' => 'string'], - // ] - // ) - // ), - // ] - // ), - // 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', - // ), - // ] - // )] - // public function execute_command_by_uuid(Request $request) - // { - // // TODO: Need to review this from security perspective, to not allow arbitrary command execution - // $allowedFields = ['command']; - // $teamId = getTeamIdFromToken(); - // if (is_null($teamId)) { - // return invalidTokenResponse(); - // } - // $uuid = $request->route('uuid'); - // if (! $uuid) { - // return response()->json(['message' => 'UUID is required.'], 400); - // } - // $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); - // if (! $application) { - // return response()->json(['message' => 'Application not found.'], 404); - // } - // $return = validateIncomingRequest($request); - // if ($return instanceof \Illuminate\Http\JsonResponse) { - // return $return; - // } - // $validator = customApiValidator($request->all(), [ - // 'command' => 'string|required', - // ]); - - // $extraFields = array_diff(array_keys($request->all()), $allowedFields); - // if ($validator->fails() || ! empty($extraFields)) { - // $errors = $validator->errors(); - // if (! empty($extraFields)) { - // foreach ($extraFields as $field) { - // $errors->add($field, 'This field is not allowed.'); - // } - // } - - // return response()->json([ - // 'message' => 'Validation failed.', - // 'errors' => $errors, - // ], 422); - // } - - // $container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail(); - // $status = getContainerStatus($application->destination->server, $container['Names']); - - // if ($status !== 'running') { - // return response()->json([ - // 'message' => 'Application is not running.', - // ], 400); - // } - - // $commands = collect([ - // executeInDocker($container['Names'], $request->command), - // ]); - - // $res = instant_remote_process(command: $commands, server: $application->destination->server); - - // return response()->json([ - // 'message' => 'Command executed.', - // 'response' => $res, - // ]); - // } - private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); @@ -3254,14 +3392,23 @@ class ApplicationsController extends Controller 'errors' => $errors, ], 422); } - if (checkIfDomainIsAlreadyUsed($fqdn, $teamId, $uuid)) { + // Check for domain conflicts + $result = checkIfDomainIsAlreadyUsedViaAPI($fqdn, $teamId, $uuid); + if (isset($result['error'])) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => [ - 'domains' => 'One of the domain is already used.', - ], + 'errors' => ['domains' => $result['error']], ], 422); } + + // If there are conflicts and force is not enabled, return warning + if ($result['hasConflicts'] && ! $request->boolean('force_domain_override')) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } } } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 504665f6a..389d119bd 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Models\Project; use App\Models\Server; +use App\Models\StandalonePostgresql; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -143,6 +144,8 @@ class DatabasesController extends Controller return response()->json(['message' => 'Database not found.'], 404); } + $this->authorize('view', $database); + return response()->json($this->removeSensitiveData($database)); } @@ -276,6 +279,9 @@ class DatabasesController extends Controller if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } + + $this->authorize('update', $database); + if ($request->is_public && $request->public_port) { if (isPublicPortAlreadyUsed($database->destination->server, $request->public_port, $database->id)) { return response()->json(['message' => 'Public port already used by another database.'], 400); @@ -1028,6 +1034,9 @@ class DatabasesController extends Controller return invalidTokenResponse(); } + // Use a generic authorization for database creation - using PostgreSQL as representative model + $this->authorize('create', StandalonePostgresql::class); + $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -1606,12 +1615,14 @@ class DatabasesController extends Controller return response()->json(['message' => 'Database not found.'], 404); } + $this->authorize('delete', $database); + DeleteResourceJob::dispatch( resource: $database, - deleteConfigurations: $request->query->get('delete_configurations', true), deleteVolumes: $request->query->get('delete_volumes', true), - dockerCleanup: $request->query->get('docker_cleanup', true), - deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) + deleteConnectedNetworks: $request->query->get('delete_connected_networks', true), + deleteConfigurations: $request->query->get('delete_configurations', true), + dockerCleanup: $request->query->get('docker_cleanup', true) ); return response()->json([ @@ -1684,6 +1695,9 @@ class DatabasesController extends Controller if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } + + $this->authorize('manage', $database); + if (str($database->status)->contains('running')) { return response()->json(['message' => 'Database is already running.'], 400); } @@ -1762,6 +1776,9 @@ class DatabasesController extends Controller if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } + + $this->authorize('manage', $database); + if (str($database->status)->contains('stopped') || str($database->status)->contains('exited')) { return response()->json(['message' => 'Database is already stopped.'], 400); } @@ -1840,6 +1857,9 @@ class DatabasesController extends Controller if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } + + $this->authorize('manage', $database); + RestartDatabase::dispatch($database); return response()->json( diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index 5c7f20902..c4d603392 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -225,6 +225,14 @@ class DeployController extends Controller foreach ($uuids as $uuid) { $resource = getResourceByUuid($uuid, $teamId); if ($resource) { + if ($pr !== 0) { + $preview = $resource->previews()->where('pull_request_id', $pr)->first(); + if (! $preview) { + $deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]); + + continue; + } + } ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr); if ($deployment_uuid) { $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]); @@ -299,6 +307,12 @@ class DeployController extends Controller } switch ($resource?->getMorphClass()) { case Application::class: + // Check authorization for application deployment + try { + $this->authorize('deploy', $resource); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null]; + } $deployment_uuid = new Cuid2; $result = queue_application_deployment( application: $resource, @@ -313,11 +327,22 @@ class DeployController extends Controller } break; case Service::class: + // Check authorization for service deployment + try { + $this->authorize('deploy', $resource); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null]; + } StartService::run($resource); $message = "Service {$resource->name} started. It could take a while, be patient."; break; default: - // Database resource + // Database resource - check authorization + try { + $this->authorize('manage', $resource); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null]; + } StartDatabase::dispatch($resource); $resource->started_at ??= now(); @@ -423,6 +448,10 @@ class DeployController extends Controller if (is_null($application)) { return response()->json(['message' => 'Application not found'], 404); } + + // Check authorization to view application deployments + $this->authorize('view', $application); + $deployments = $application->deployments($skip, $take); return response()->json($deployments); diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 98637c3e8..e688b8980 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Project; +use App\Support\ValidationPatterns; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; class ProjectController extends Controller @@ -227,10 +229,10 @@ class ProjectController extends Controller if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255|required', - 'description' => 'string|nullable', - ]); + $validator = Validator::make($request->all(), [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ], ValidationPatterns::combinedMessages()); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -337,10 +339,10 @@ class ProjectController extends Controller if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - $validator = customApiValidator($request->all(), [ - 'name' => 'string|max:255|nullable', - 'description' => 'string|nullable', - ]); + $validator = Validator::make($request->all(), [ + 'name' => ValidationPatterns::nameRules(required: false), + 'description' => ValidationPatterns::descriptionRules(), + ], ValidationPatterns::combinedMessages()); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -447,4 +449,255 @@ class ProjectController extends Controller return response()->json(['message' => 'Project deleted.']); } + + #[OA\Get( + summary: 'List Environments', + description: 'List all environments in a project.', + path: '/projects/{uuid}/environments', + operationId: 'get-environments', + security: [ + ['bearerAuth' => []], + ], + tags: ['Projects'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of environments', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(ref: '#/components/schemas/Environment') + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + description: 'Project not found.', + ), + ] + )] + public function get_environments(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + if (! $request->uuid) { + return response()->json(['message' => 'Project UUID is required.'], 422); + } + + $project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + + $environments = $project->environments()->select('id', 'name', 'uuid')->get(); + + return response()->json(serializeApiResponse($environments)); + } + + #[OA\Post( + summary: 'Create Environment', + description: 'Create environment in project.', + path: '/projects/{uuid}/environments', + operationId: 'create-environment', + security: [ + ['bearerAuth' => []], + ], + tags: ['Projects'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'Environment created.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The name of the environment.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Environment created.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string', 'example' => 'env123', 'description' => 'The UUID of the environment.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + description: 'Project not found.', + ), + new OA\Response( + response: 409, + description: 'Environment with this name already exists.', + ), + ] + )] + public function create_environment(Request $request) + { + $allowedFields = ['name']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + $validator = Validator::make($request->all(), [ + 'name' => ValidationPatterns::nameRules(), + ], ValidationPatterns::nameMessages()); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + if (! $request->uuid) { + return response()->json(['message' => 'Project UUID is required.'], 422); + } + + $project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + + $existingEnvironment = $project->environments()->where('name', $request->name)->first(); + if ($existingEnvironment) { + return response()->json(['message' => 'Environment with this name already exists.'], 409); + } + + $environment = $project->environments()->create([ + 'name' => $request->name, + ]); + + return response()->json([ + 'uuid' => $environment->uuid, + ])->setStatusCode(201); + } + + #[OA\Delete( + summary: 'Delete Environment', + description: 'Delete environment by name or UUID. Environment must be empty.', + path: '/projects/{uuid}/environments/{environment_name_or_uuid}', + operationId: 'delete-environment', + security: [ + ['bearerAuth' => []], + ], + tags: ['Projects'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Environment deleted.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Environment deleted.'], + ] + ) + ), + ]), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + description: 'Environment has resources, so it cannot be deleted.', + ), + new OA\Response( + response: 404, + description: 'Project or environment not found.', + ), + ] + )] + public function delete_environment(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + if (! $request->uuid) { + return response()->json(['message' => 'Project UUID is required.'], 422); + } + if (! $request->environment_name_or_uuid) { + return response()->json(['message' => 'Environment name or UUID is required.'], 422); + } + + $project = Project::whereTeamId($teamId)->whereUuid($request->uuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + + $environment = $project->environments()->whereName($request->environment_name_or_uuid)->first(); + if (! $environment) { + $environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first(); + } + if (! $environment) { + return response()->json(['message' => 'Environment not found.'], 404); + } + + if (! $environment->isEmpty()) { + return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400); + } + + $environment->delete(); + + return response()->json(['message' => 'Environment deleted.']); + } } diff --git a/app/Http/Controllers/Api/ResourcesController.php b/app/Http/Controllers/Api/ResourcesController.php index ad12c83ab..d5dc4a046 100644 --- a/app/Http/Controllers/Api/ResourcesController.php +++ b/app/Http/Controllers/Api/ResourcesController.php @@ -43,6 +43,10 @@ class ResourcesController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } + + // General authorization check for viewing resources - using Project as base resource type + $this->authorize('viewAny', Project::class); + $projects = Project::where('team_id', $teamId)->get(); $resources = collect(); $resources->push($projects->pluck('applications')->flatten()); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 542be83de..e240e326e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -246,6 +246,8 @@ class ServicesController extends Controller return invalidTokenResponse(); } + $this->authorize('create', Service::class); + $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -351,7 +353,6 @@ class ServicesController extends Controller 'value' => $generatedValue, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), - 'is_build_time' => false, 'is_preview' => false, ]); }); @@ -377,14 +378,118 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network']; - $service = new Service; - $result = $this->upsert_service($request, $service, $teamId); - if ($result instanceof \Illuminate\Http\JsonResponse) { - return $result; + $validator = customApiValidator($request->all(), [ + 'project_uuid' => 'string|required', + 'environment_name' => 'string|nullable', + 'environment_uuid' => 'string|nullable', + 'server_uuid' => 'string|required', + 'destination_uuid' => 'string', + 'name' => 'string|max:255', + 'description' => 'string|nullable', + 'instant_deploy' => 'boolean', + 'connect_to_docker_network' => 'boolean', + 'docker_compose_raw' => 'string|required', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); } - return response()->json(serializeApiResponse($result))->setStatusCode(201); + $environmentUuid = $request->environment_uuid; + $environmentName = $request->environment_name; + if (blank($environmentUuid) && blank($environmentName)) { + return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422); + } + $serverUuid = $request->server_uuid; + $projectUuid = $request->project_uuid; + $project = Project::whereTeamId($teamId)->whereUuid($projectUuid)->first(); + if (! $project) { + return response()->json(['message' => 'Project not found.'], 404); + } + $environment = $project->environments()->where('name', $environmentName)->first(); + if (! $environment) { + $environment = $project->environments()->where('uuid', $environmentUuid)->first(); + } + if (! $environment) { + return response()->json(['message' => 'Environment not found.'], 404); + } + $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + $destinations = $server->destinations(); + if ($destinations->count() == 0) { + return response()->json(['message' => 'Server has no destinations.'], 400); + } + if ($destinations->count() > 1 && ! $request->has('destination_uuid')) { + return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); + } + $destination = $destinations->first(); + if (! isBase64Encoded($request->docker_compose_raw)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', + ], + ], 422); + } + $dockerComposeRaw = base64_decode($request->docker_compose_raw); + if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', + ], + ], 422); + } + $dockerCompose = base64_decode($request->docker_compose_raw); + $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + + $connectToDockerNetwork = $request->connect_to_docker_network ?? false; + $instantDeploy = $request->instant_deploy ?? false; + + $service = new Service; + $service->name = $request->name ?? 'service-'.str()->random(10); + $service->description = $request->description; + $service->docker_compose_raw = $dockerComposeRaw; + $service->environment_id = $environment->id; + $service->server_id = $server->id; + $service->destination_id = $destination->id; + $service->destination_type = $destination->getMorphClass(); + $service->connect_to_docker_network = $connectToDockerNetwork; + $service->save(); + + $service->parse(isNew: true); + if ($instantDeploy) { + StartService::dispatch($service); + } + + $domains = $service->applications()->get()->pluck('fqdn')->sort(); + $domains = $domains->map(function ($domain) { + if (count(explode(':', $domain)) > 2) { + return str($domain)->beforeLast(':')->value(); + } + + return $domain; + })->values(); + + return response()->json([ + 'uuid' => $service->uuid, + 'domains' => $domains, + ])->setStatusCode(201); } else { return response()->json(['message' => 'No service type or docker_compose_raw provided.'], 400); } @@ -443,6 +548,8 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('view', $service); + $service = $service->load(['applications', 'databases']); return response()->json($this->removeSensitiveData($service)); @@ -508,12 +615,14 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('delete', $service); + DeleteResourceJob::dispatch( resource: $service, - deleteConfigurations: $request->query->get('delete_configurations', true), deleteVolumes: $request->query->get('delete_volumes', true), - dockerCleanup: $request->query->get('docker_cleanup', true), - deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) + deleteConnectedNetworks: $request->query->get('delete_connected_networks', true), + deleteConfigurations: $request->query->get('delete_configurations', true), + dockerCleanup: $request->query->get('docker_cleanup', true) ); return response()->json([ @@ -550,7 +659,6 @@ class ServicesController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'], properties: [ 'name' => ['type' => 'string', 'description' => 'The service name.'], 'description' => ['type' => 'string', 'description' => 'The service description.'], @@ -615,28 +723,16 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.'], 404); } - $result = $this->upsert_service($request, $service, $teamId); - if ($result instanceof \Illuminate\Http\JsonResponse) { - return $result; - } + $this->authorize('update', $service); - return response()->json(serializeApiResponse($result))->setStatusCode(200); - } + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network']; - private function upsert_service(Request $request, Service $service, string $teamId) - { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network']; $validator = customApiValidator($request->all(), [ - 'project_uuid' => 'string|required', - 'environment_name' => 'string|nullable', - 'environment_uuid' => 'string|nullable', - 'server_uuid' => 'string|required', - 'destination_uuid' => 'string', 'name' => 'string|max:255', 'description' => 'string|nullable', 'instant_deploy' => 'boolean', 'connect_to_docker_network' => 'boolean', - 'docker_compose_raw' => 'string|required', + 'docker_compose_raw' => 'string|nullable', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -653,70 +749,42 @@ class ServicesController extends Controller 'errors' => $errors, ], 422); } + if ($request->has('docker_compose_raw')) { + if (! isBase64Encoded($request->docker_compose_raw)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', + ], + ], 422); + } + $dockerComposeRaw = base64_decode($request->docker_compose_raw); + if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', + ], + ], 422); + } + $dockerCompose = base64_decode($request->docker_compose_raw); + $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + $service->docker_compose_raw = $dockerComposeRaw; + } - $environmentUuid = $request->environment_uuid; - $environmentName = $request->environment_name; - if (blank($environmentUuid) && blank($environmentName)) { - return response()->json(['message' => 'You need to provide at least one of environment_name or environment_uuid.'], 422); + if ($request->has('name')) { + $service->name = $request->name; } - $serverUuid = $request->server_uuid; - $instantDeploy = $request->instant_deploy ?? false; - $project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first(); - if (! $project) { - return response()->json(['message' => 'Project not found.'], 404); + if ($request->has('description')) { + $service->description = $request->description; } - $environment = $project->environments()->where('name', $environmentName)->first(); - if (! $environment) { - $environment = $project->environments()->where('uuid', $environmentUuid)->first(); + if ($request->has('connect_to_docker_network')) { + $service->connect_to_docker_network = $request->connect_to_docker_network; } - if (! $environment) { - return response()->json(['message' => 'Environment not found.'], 404); - } - $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); - if (! $server) { - return response()->json(['message' => 'Server not found.'], 404); - } - $destinations = $server->destinations(); - if ($destinations->count() == 0) { - return response()->json(['message' => 'Server has no destinations.'], 400); - } - if ($destinations->count() > 1 && ! $request->has('destination_uuid')) { - return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400); - } - $destination = $destinations->first(); - if (! isBase64Encoded($request->docker_compose_raw)) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => [ - 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', - ], - ], 422); - } - $dockerComposeRaw = base64_decode($request->docker_compose_raw); - if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => [ - 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.', - ], - ], 422); - } - $dockerCompose = base64_decode($request->docker_compose_raw); - $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); - $connectToDockerNetwork = $request->connect_to_docker_network ?? false; - - $service->name = $request->name ?? null; - $service->description = $request->description ?? null; - $service->docker_compose_raw = $dockerComposeRaw; - $service->environment_id = $environment->id; - $service->server_id = $server->id; - $service->destination_id = $destination->id; - $service->destination_type = $destination->getMorphClass(); - $service->connect_to_docker_network = $connectToDockerNetwork; $service->save(); $service->parse(); - if ($instantDeploy) { + if ($request->instant_deploy) { StartService::dispatch($service); } @@ -729,10 +797,10 @@ class ServicesController extends Controller return $domain; })->values(); - return [ + return response()->json([ 'uuid' => $service->uuid, 'domains' => $domains, - ]; + ])->setStatusCode(200); } #[OA\Get( @@ -795,6 +863,8 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('manageEnvironment', $service); + $envs = $service->environment_variables->map(function ($env) { $env->makeHidden([ 'application_id', @@ -848,7 +918,6 @@ class ServicesController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -899,10 +968,11 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('manageEnvironment', $service); + $validator = customApiValidator($request->all(), [ 'key' => 'string|required', 'value' => 'string|nullable', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -966,7 +1036,6 @@ class ServicesController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -1020,6 +1089,8 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('manageEnvironment', $service); + $bulk_data = $request->get('data'); if (! $bulk_data) { return response()->json(['message' => 'Bulk data is required.'], 400); @@ -1030,7 +1101,6 @@ class ServicesController extends Controller $validator = customApiValidator($item, [ 'key' => 'string|required', 'value' => 'string|nullable', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -1086,7 +1156,6 @@ class ServicesController extends Controller 'key' => ['type' => 'string', 'description' => 'The key of the environment variable.'], 'value' => ['type' => 'string', 'description' => 'The value of the environment variable.'], 'is_preview' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in preview deployments.'], - 'is_build_time' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is used in build time.'], 'is_literal' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.'], 'is_multiline' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable is multiline.'], 'is_shown_once' => ['type' => 'boolean', 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.'], @@ -1136,10 +1205,11 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('manageEnvironment', $service); + $validator = customApiValidator($request->all(), [ 'key' => 'string|required', 'value' => 'string|nullable', - 'is_build_time' => 'boolean', 'is_literal' => 'boolean', 'is_multiline' => 'boolean', 'is_shown_once' => 'boolean', @@ -1238,6 +1308,8 @@ class ServicesController extends Controller return response()->json(['message' => 'Service not found.'], 404); } + $this->authorize('manageEnvironment', $service); + $env = EnvironmentVariable::where('uuid', $request->env_uuid) ->where('resourceable_type', Service::class) ->where('resourceable_id', $service->id) @@ -1317,6 +1389,9 @@ class ServicesController extends Controller if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } + + $this->authorize('deploy', $service); + if (str($service->status)->contains('running')) { return response()->json(['message' => 'Service is already running.'], 400); } @@ -1395,6 +1470,9 @@ class ServicesController extends Controller if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } + + $this->authorize('stop', $service); + if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) { return response()->json(['message' => 'Service is already stopped.'], 400); } @@ -1482,6 +1560,9 @@ class ServicesController extends Controller if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } + + $this->authorize('deploy', $service); + $pullLatest = $request->boolean('latest'); RestartService::dispatch($service, $pullLatest); diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index d4b24d8ab..e12d83542 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -179,6 +179,8 @@ class TeamController extends Controller $members = $team->members; $members->makeHidden([ 'pivot', + 'email_change_code', + 'email_change_code_expires_at', ]); return response()->json( @@ -264,6 +266,8 @@ class TeamController extends Controller $team = auth()->user()->currentTeam(); $team->members->makeHidden([ 'pivot', + 'email_change_code', + 'email_change_code_expires_at', ]); return response()->json( diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 8872754e5..5ba9c08e7 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook; use App\Enums\ProcessStatus; use App\Http\Controllers\Controller; use App\Jobs\ApplicationPullRequestUpdateJob; +use App\Jobs\DeleteResourceJob; use App\Jobs\GithubAppPermissionJob; use App\Models\Application; use App\Models\ApplicationPreview; @@ -78,6 +79,7 @@ class Github extends Controller $pull_request_html_url = data_get($payload, 'pull_request.html_url'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); + $author_association = data_get($payload, 'pull_request.author_association'); } if (! $branch) { return response('Nothing to do. No branch found in the request.'); @@ -95,151 +97,168 @@ class Github extends Controller return response("Nothing to do. No applications found with branch '$base_branch'."); } } - foreach ($applications as $application) { - $webhook_secret = data_get($application, 'manual_webhook_secret_github'); - $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); - if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Invalid signature.', - ]); + $applicationsByServer = $applications->groupBy(function ($app) { + return $app->destination->server_id; + }); - continue; - } - $isFunctional = $application->destination->server->isFunctional(); - if (! $isFunctional) { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Server is not functional.', - ]); + foreach ($applicationsByServer as $serverId => $serverApplications) { + foreach ($serverApplications as $application) { + $webhook_secret = data_get($application, 'manual_webhook_secret_github'); + $hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret); + if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Invalid signature.', + ]); - continue; - } - if ($x_github_event === 'push') { - if ($application->isDeployable()) { - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if ($is_watch_path_triggered || is_null($application->watch_paths)) { - $deployment_uuid = new Cuid2; - $result = queue_application_deployment( - application: $application, - deployment_uuid: $deployment_uuid, - force_rebuild: false, - commit: data_get($payload, 'after', 'HEAD'), - is_webhook: true, - ); - if ($result['status'] === 'skipped') { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'skipped', - 'message' => $result['message'], - ]); + continue; + } + $isFunctional = $application->destination->server->isFunctional(); + if (! $isFunctional) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Server is not functional.', + ]); + + continue; + } + if ($x_github_event === 'push') { + if ($application->isDeployable()) { + $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); + if ($is_watch_path_triggered || is_null($application->watch_paths)) { + $deployment_uuid = new Cuid2; + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + commit: data_get($payload, 'after', 'HEAD'), + is_webhook: true, + ); + if ($result['status'] === 'skipped') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => $result['message'], + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Deployment queued.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + ]); + } } else { + $paths = str($application->watch_paths)->explode("\n"); $return_payloads->push([ - 'application' => $application->name, - 'status' => 'success', - 'message' => 'Deployment queued.', + 'status' => 'failed', + 'message' => 'Changed files do not match watch paths. Ignoring deployment.', 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'deployment_uuid' => $result['deployment_uuid'], + 'details' => [ + 'changed_files' => $changed_files, + 'watch_paths' => $paths, + ], ]); } } else { - $paths = str($application->watch_paths)->explode("\n"); $return_payloads->push([ 'status' => 'failed', - 'message' => 'Changed files do not match watch paths. Ignoring deployment.', + 'message' => 'Deployments disabled.', 'application_uuid' => $application->uuid, 'application_name' => $application->name, - 'details' => [ - 'changed_files' => $changed_files, - 'watch_paths' => $paths, - ], ]); } - } else { - $return_payloads->push([ - 'status' => 'failed', - 'message' => 'Deployments disabled.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - ]); } - } - if ($x_github_event === 'pull_request') { - if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { - if ($application->isPRDeployable()) { - $deployment_uuid = new Cuid2; - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if (! $found) { - if ($application->build_pack === 'dockercompose') { - $pr_app = ApplicationPreview::create([ - 'git_type' => 'github', - 'application_id' => $application->id, - 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, - 'docker_compose_domains' => $application->docker_compose_domains, - ]); - $pr_app->generate_preview_fqdn_compose(); - } else { - $pr_app = ApplicationPreview::create([ - 'git_type' => 'github', - 'application_id' => $application->id, - 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, - ]); - $pr_app->generate_preview_fqdn(); - } - } + if ($x_github_event === 'pull_request') { + if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { + if ($application->isPRDeployable()) { + // Check if PR deployments from public contributors are restricted + if (! $application->settings->is_pr_deployments_public_enabled) { + $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; + if (! in_array($author_association, $trustedAssociations)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association, + ]); - $result = queue_application_deployment( - application: $application, - pull_request_id: $pull_request_id, - deployment_uuid: $deployment_uuid, - force_rebuild: false, - commit: data_get($payload, 'head.sha', 'HEAD'), - is_webhook: true, - git_type: 'github' - ); - if ($result['status'] === 'skipped') { + continue; + } + } + $deployment_uuid = new Cuid2; + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if (! $found) { + if ($application->build_pack === 'dockercompose') { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'github', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + $pr_app->generate_preview_fqdn_compose(); + } else { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'github', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + ]); + $pr_app->generate_preview_fqdn(); + } + } + + $result = queue_application_deployment( + application: $application, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + commit: data_get($payload, 'head.sha', 'HEAD'), + is_webhook: true, + git_type: 'github' + ); + if ($result['status'] === 'skipped') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => $result['message'], + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment queued.', + ]); + } + } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'skipped', - 'message' => $result['message'], + 'status' => 'failed', + 'message' => 'Preview deployments disabled.', + ]); + } + } + if ($action === 'closed') { + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if ($found) { + DeleteResourceJob::dispatch($found); + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment closed.', ]); } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment queued.', + 'status' => 'failed', + 'message' => 'No preview deployment found.', ]); } - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Preview deployments disabled.', - ]); - } - } - if ($action === 'closed') { - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment closed.', - ]); - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'No preview deployment found.', - ]); } } } @@ -327,6 +346,7 @@ class Github extends Controller $pull_request_html_url = data_get($payload, 'pull_request.html_url'); $branch = data_get($payload, 'pull_request.head.ref'); $base_branch = data_get($payload, 'pull_request.base.ref'); + $author_association = data_get($payload, 'pull_request.author_association'); } if (! $id || ! $branch) { return response('Nothing to do. No id or branch found.'); @@ -344,127 +364,147 @@ class Github extends Controller return response("Nothing to do. No applications found with branch '$base_branch'."); } } - foreach ($applications as $application) { - $isFunctional = $application->destination->server->isFunctional(); - if (! $isFunctional) { - $return_payloads->push([ - 'status' => 'failed', - 'message' => 'Server is not functional.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - ]); + $applicationsByServer = $applications->groupBy(function ($app) { + return $app->destination->server_id; + }); - continue; - } - if ($x_github_event === 'push') { - if ($application->isDeployable()) { - $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); - if ($is_watch_path_triggered || is_null($application->watch_paths)) { - $deployment_uuid = new Cuid2; - $result = queue_application_deployment( - application: $application, - deployment_uuid: $deployment_uuid, - commit: data_get($payload, 'after', 'HEAD'), - force_rebuild: false, - is_webhook: true, - ); - $return_payloads->push([ - 'status' => $result['status'], - 'message' => $result['message'], - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'deployment_uuid' => $result['deployment_uuid'], - ]); - } else { - $paths = str($application->watch_paths)->explode("\n"); - $return_payloads->push([ - 'status' => 'failed', - 'message' => 'Changed files do not match watch paths. Ignoring deployment.', - 'application_uuid' => $application->uuid, - 'application_name' => $application->name, - 'details' => [ - 'changed_files' => $changed_files, - 'watch_paths' => $paths, - ], - ]); - } - } else { + foreach ($applicationsByServer as $serverId => $serverApplications) { + foreach ($serverApplications as $application) { + $isFunctional = $application->destination->server->isFunctional(); + if (! $isFunctional) { $return_payloads->push([ 'status' => 'failed', - 'message' => 'Deployments disabled.', + 'message' => 'Server is not functional.', 'application_uuid' => $application->uuid, 'application_name' => $application->name, ]); + + continue; } - } - if ($x_github_event === 'pull_request') { - if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { - if ($application->isPRDeployable()) { - $deployment_uuid = new Cuid2; - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if (! $found) { - ApplicationPreview::create([ - 'git_type' => 'github', - 'application_id' => $application->id, - 'pull_request_id' => $pull_request_id, - 'pull_request_html_url' => $pull_request_html_url, + if ($x_github_event === 'push') { + if ($application->isDeployable()) { + $is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files); + if ($is_watch_path_triggered || is_null($application->watch_paths)) { + $deployment_uuid = new Cuid2; + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deployment_uuid, + commit: data_get($payload, 'after', 'HEAD'), + force_rebuild: false, + is_webhook: true, + ); + $return_payloads->push([ + 'status' => $result['status'], + 'message' => $result['message'], + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $result['deployment_uuid'], + ]); + } else { + $paths = str($application->watch_paths)->explode("\n"); + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Changed files do not match watch paths. Ignoring deployment.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'details' => [ + 'changed_files' => $changed_files, + 'watch_paths' => $paths, + ], ]); } - $result = queue_application_deployment( - application: $application, - pull_request_id: $pull_request_id, - deployment_uuid: $deployment_uuid, - force_rebuild: false, - commit: data_get($payload, 'head.sha', 'HEAD'), - is_webhook: true, - git_type: 'github' - ); - if ($result['status'] === 'skipped') { + } else { + $return_payloads->push([ + 'status' => 'failed', + 'message' => 'Deployments disabled.', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + ]); + } + } + if ($x_github_event === 'pull_request') { + if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') { + if ($application->isPRDeployable()) { + // Check if PR deployments from public contributors are restricted + if (! $application->settings->is_pr_deployments_public_enabled) { + $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR']; + if (! in_array($author_association, $trustedAssociations)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association, + ]); + + continue; + } + } + $deployment_uuid = new Cuid2; + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if (! $found) { + ApplicationPreview::create([ + 'git_type' => 'github', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + ]); + } + $result = queue_application_deployment( + application: $application, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + force_rebuild: false, + commit: data_get($payload, 'head.sha', 'HEAD'), + is_webhook: true, + git_type: 'github' + ); + if ($result['status'] === 'skipped') { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => $result['message'], + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment queued.', + ]); + } + } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'skipped', - 'message' => $result['message'], + 'status' => 'failed', + 'message' => 'Preview deployments disabled.', + ]); + } + } + if ($action === 'closed' || $action === 'close') { + $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); + if ($found) { + $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id); + if ($containers->isNotEmpty()) { + $containers->each(function ($container) use ($application) { + $container_name = data_get($container, 'Names'); + instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + }); + } + + ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED); + + DeleteResourceJob::dispatch($found); + + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment closed.', ]); } else { $return_payloads->push([ 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment queued.', + 'status' => 'failed', + 'message' => 'No preview deployment found.', ]); } - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'Preview deployments disabled.', - ]); - } - } - if ($action === 'closed' || $action === 'close') { - $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); - if ($found) { - $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id); - if ($containers->isNotEmpty()) { - $containers->each(function ($container) use ($application) { - $container_name = data_get($container, 'Names'); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); - }); - } - - ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED); - $found->delete(); - - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'success', - 'message' => 'Preview deployment closed.', - ]); - } else { - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'No preview deployment found.', - ]); } } } diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index 83ba16699..ae50aac42 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -4,15 +4,12 @@ namespace App\Http\Controllers\Webhook; use App\Http\Controllers\Controller; use App\Jobs\StripeProcessJob; -use App\Models\Webhook; use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; class Stripe extends Controller { - protected $webhook; - public function events(Request $request) { try { @@ -40,19 +37,10 @@ class Stripe extends Controller return response('Webhook received. Cool cool cool cool cool.', 200); } - $this->webhook = Webhook::create([ - 'type' => 'stripe', - 'payload' => $request->getContent(), - ]); StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); } catch (Exception $e) { - $this->webhook->update([ - 'status' => 'failed', - 'failure_reason' => $e->getMessage(), - ]); - return response($e->getMessage(), 400); } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index a1ce20295..e9d7b82b2 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -71,5 +71,8 @@ class Kernel extends HttpKernel 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, 'api.ability' => \App\Http\Middleware\ApiAbility::class, 'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class, + 'can.create.resources' => \App\Http\Middleware\CanCreateResources::class, + 'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class, + 'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class, ]; } diff --git a/app/Http/Middleware/ApiAllowed.php b/app/Http/Middleware/ApiAllowed.php index dc6be5da3..21441a117 100644 --- a/app/Http/Middleware/ApiAllowed.php +++ b/app/Http/Middleware/ApiAllowed.php @@ -18,12 +18,18 @@ class ApiAllowed return response()->json(['success' => true, 'message' => 'API is disabled.'], 403); } - if (! isDev()) { - if ($settings->allowed_ips) { - $allowedIps = explode(',', $settings->allowed_ips); - if (! in_array($request->ip(), $allowedIps)) { - return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); - } + if ($settings->allowed_ips) { + // Check for special case: 0.0.0.0 means allow all + if (trim($settings->allowed_ips) === '0.0.0.0') { + return $next($request); + } + + $allowedIps = explode(',', $settings->allowed_ips); + $allowedIps = array_map('trim', $allowedIps); + $allowedIps = array_filter($allowedIps); // Remove empty entries + + if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) { + return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); } } diff --git a/app/Http/Middleware/CanAccessTerminal.php b/app/Http/Middleware/CanAccessTerminal.php new file mode 100644 index 000000000..348f389ea --- /dev/null +++ b/app/Http/Middleware/CanAccessTerminal.php @@ -0,0 +1,29 @@ +check()) { + abort(401, 'Authentication required'); + } + + // Only admins/owners can access terminal functionality + if (! auth()->user()->can('canAccessTerminal')) { + abort(403, 'Access to terminal functionality is restricted to team administrators'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/CanCreateResources.php b/app/Http/Middleware/CanCreateResources.php new file mode 100644 index 000000000..ba0ab67c1 --- /dev/null +++ b/app/Http/Middleware/CanCreateResources.php @@ -0,0 +1,26 @@ +route('application_uuid')) { + // $resource = Application::where('uuid', $request->route('application_uuid'))->first(); + // } elseif ($request->route('service_uuid')) { + // $resource = Service::where('uuid', $request->route('service_uuid'))->first(); + // } elseif ($request->route('stack_service_uuid')) { + // // Handle ServiceApplication or ServiceDatabase + // $stack_service_uuid = $request->route('stack_service_uuid'); + // $resource = ServiceApplication::where('uuid', $stack_service_uuid)->first() ?? + // ServiceDatabase::where('uuid', $stack_service_uuid)->first(); + // } elseif ($request->route('database_uuid')) { + // // Try different database types + // $database_uuid = $request->route('database_uuid'); + // $resource = StandalonePostgresql::where('uuid', $database_uuid)->first() ?? + // StandaloneMysql::where('uuid', $database_uuid)->first() ?? + // StandaloneMariadb::where('uuid', $database_uuid)->first() ?? + // StandaloneRedis::where('uuid', $database_uuid)->first() ?? + // StandaloneKeydb::where('uuid', $database_uuid)->first() ?? + // StandaloneDragonfly::where('uuid', $database_uuid)->first() ?? + // StandaloneClickhouse::where('uuid', $database_uuid)->first() ?? + // StandaloneMongodb::where('uuid', $database_uuid)->first(); + // } elseif ($request->route('server_uuid')) { + // // For server routes, check if user can manage servers + // if (! auth()->user()->isAdmin()) { + // abort(403, 'You do not have permission to access this resource.'); + // } + + // return $next($request); + // } elseif ($request->route('environment_uuid')) { + // $resource = Environment::where('uuid', $request->route('environment_uuid'))->first(); + // } elseif ($request->route('project_uuid')) { + // $resource = Project::ownedByCurrentTeam()->where('uuid', $request->route('project_uuid'))->first(); + // } + + // if (! $resource) { + // abort(404, 'Resource not found.'); + // } + + // if (! Gate::allows('update', $resource)) { + // abort(403, 'You do not have permission to update this resource.'); + // } + + // return $next($request); + } +} diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 07d4ea9a0..7fa12757e 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -5,6 +5,7 @@ namespace App\Jobs; use App\Actions\Docker\GetContainersStatus; use App\Enums\ApplicationDeploymentStatus; use App\Enums\ProcessStatus; +use App\Events\ApplicationConfigurationChanged; use App\Events\ServiceStatusChanged; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; @@ -147,6 +148,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private Collection $saved_outputs; + private ?string $secrets_hash_key = null; + private ?string $full_healthcheck_url = null; private string $serverUser = 'root'; @@ -167,6 +170,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $preserveRepository = false; + private bool $dockerBuildkitSupported = false; + + private bool $skip_build = false; + + private Collection|string $build_secrets; + public function tags() { // Do not remove this one, it needs to properly identify which worker is running the job @@ -183,6 +192,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application = Application::find($this->application_deployment_queue->application_id); $this->build_pack = data_get($this->application, 'build_pack'); $this->build_args = collect([]); + $this->build_secrets = ''; $this->deployment_uuid = $this->application_deployment_queue->deployment_uuid; $this->pull_request_id = $this->application_deployment_queue->pull_request_id; @@ -221,7 +231,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id === 0) { $this->container_name = $this->application->settings->custom_internal_name; } else { - $this->container_name = "{$this->application->settings->custom_internal_name}-pr-{$this->pull_request_id}"; + $this->container_name = addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id); } } @@ -250,6 +260,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + // Check if deployment was cancelled before we even started + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + $this->application_deployment_queue->addLogEntry('Deployment was cancelled before starting.'); + + return; + } + $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, 'horizon_job_worker' => gethostname(), @@ -263,7 +281,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue try { // Make sure the private key is stored in the filesystem $this->server->privateKey->storeInFileSystem(); - // Generate custom host<->ip mapping $allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server); @@ -319,6 +336,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->build_server = $this->server; $this->original_server = $this->server; } + $this->detectBuildKitCapabilities(); $this->decide_what_to_do(); } catch (Exception $e) { if ($this->pull_request_id !== 0 && $this->application->is_github_based()) { @@ -336,6 +354,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $this->write_deployment_configurations(); } + $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}"); $this->graceful_shutdown_container($this->deployment_uuid); @@ -343,6 +362,80 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + private function detectBuildKitCapabilities(): void + { + // If build secrets are not enabled, skip detection and use traditional args + if (! $this->application->settings->use_build_secrets) { + $this->dockerBuildkitSupported = false; + + return; + } + + $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; + $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; + + try { + $dockerVersion = instant_remote_process( + ["docker version --format '{{.Server.Version}}'"], + $serverToCheck + ); + + $versionParts = explode('.', $dockerVersion); + $majorVersion = (int) $versionParts[0]; + $minorVersion = (int) ($versionParts[1] ?? 0); + + if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled."); + + return; + } + + $buildkitEnabled = instant_remote_process( + ["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"], + $serverToCheck + ); + + if (trim($buildkitEnabled) !== 'available') { + $buildkitTest = instant_remote_process( + ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], + $serverToCheck + ); + + if (trim($buildkitTest) === 'supported') { + $this->dockerBuildkitSupported = true; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}."); + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } else { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support."); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.'); + } + } else { + // Buildx is available, which means BuildKit is available + // Now specifically test for secrets support + $secretsTest = instant_remote_process( + ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], + $serverToCheck + ); + + if (trim($secretsTest) === 'supported') { + $this->dockerBuildkitSupported = true; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); + } else { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported."); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.'); + } + } + } catch (\Exception $e) { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.'); + } + } + private function decide_what_to_do() { if ($this->restart_only) { @@ -471,14 +564,23 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } $this->generate_image_names(); $this->cleanup_git(); + + $this->generate_build_env_variables(); + $this->application->loadComposeFile(isInit: false); if ($this->application->settings->is_raw_compose_deployment_enabled) { $this->application->oldRawParser(); $yaml = $composeFile = $this->application->docker_compose_raw; - $this->save_environment_variables(); + $this->generate_runtime_environment_variables(); + + // For raw compose, we cannot automatically add secrets configuration + // User must define it manually in their docker-compose file + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); + } } else { $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); - $this->save_environment_variables(); + $this->generate_runtime_environment_variables(); if (filled($this->env_filename)) { $services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) { @@ -494,6 +596,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return; } + + // Add build secrets to compose file if enabled and BuildKit is supported + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $composeFile = $this->add_build_secrets_to_compose($composeFile); + } + $yaml = Yaml::dump(convertToArray($composeFile), 10); } $this->docker_compose_base64 = base64_encode($yaml); @@ -501,16 +609,28 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"), 'hidden' => true, ]); + + // Modify Dockerfiles for ARGs and build secrets + $this->modify_dockerfiles_for_compose($composeFile); // Build new container to limit downtime. $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); if ($this->docker_compose_custom_build_command) { + // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported + $build_command = $this->docker_compose_custom_build_command; + if ($this->dockerBuildkitSupported) { + $build_command = "DOCKER_BUILDKIT=1 {$build_command}"; + } $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$this->docker_compose_custom_build_command}"), 'hidden' => true], + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true], ); } else { $command = "{$this->coolify_variables} docker compose"; - if ($this->env_filename) { + // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported + if ($this->dockerBuildkitSupported) { + $command = "DOCKER_BUILDKIT=1 {$command}"; + } + if (filled($this->env_filename)) { $command .= " --env-file {$this->workdir}/{$this->env_filename}"; } if ($this->force_rebuild) { @@ -518,6 +638,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull"; } + + if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { + $build_args_string = $this->build_args->implode(' '); + $command .= " {$build_args_string}"; + $this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.'); + } + $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], ); @@ -556,7 +683,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->docker_compose_location = '/docker-compose.yaml'; $command = "{$this->coolify_variables} docker compose"; - if ($this->env_filename) { + if (filled($this->env_filename)) { $command .= " --env-file {$server_workdir}/{$this->env_filename}"; } $command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; @@ -573,7 +700,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $command = "{$this->coolify_variables} docker compose"; if ($this->preserveRepository) { - if ($this->env_filename) { + if (filled($this->env_filename)) { $command .= " --env-file {$server_workdir}/{$this->env_filename}"; } $command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; @@ -583,7 +710,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ['command' => $command, 'hidden' => true], ); } else { - if ($this->env_filename) { + if (filled($this->env_filename)) { $command .= " --env-file {$this->workdir}/{$this->env_filename}"; } $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; @@ -647,6 +774,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->generate_compose_file(); $this->generate_build_env_variables(); $this->build_image(); + + // For Nixpacks, save runtime environment variables AFTER the build + // to prevent them from being accessible during the build process + $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -669,7 +800,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->clone_repository(); $this->cleanup_git(); $this->generate_compose_file(); - $this->build_image(); + $this->build_static_image(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -712,8 +843,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id === 0) { $composeFileName = "$mainDir/docker-compose.yaml"; } else { - $composeFileName = "$mainDir/docker-compose-pr-{$this->pull_request_id}.yaml"; - $this->docker_compose_location = "/docker-compose-pr-{$this->pull_request_id}.yaml"; + $composeFileName = "$mainDir/".addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml'; + $this->docker_compose_location = '/'.addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml'; } $this->execute_remote_command( [ @@ -840,18 +971,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { if (str($this->saved_outputs->get('local_image_found'))->isNotEmpty()) { if ($this->is_this_additional_server) { + $this->skip_build = true; $this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->generate_compose_file(); $this->push_to_docker_registry(); $this->rolling_update(); - if ($this->restart_only) { - $this->post_deployment(); - } return true; } if (! $this->application->isConfigurationChanged()) { $this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); + $this->skip_build = true; $this->generate_compose_file(); $this->push_to_docker_registry(); $this->rolling_update(); @@ -892,7 +1022,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - private function save_environment_variables() + private function generate_runtime_environment_variables() { $envs = collect([]); $sort = $this->application->settings->is_env_sorting_enabled; @@ -905,10 +1035,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } if ($this->build_pack === 'dockercompose') { $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); }); $sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) { - return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_'); }); } $ports = $this->application->main_port(); @@ -919,20 +1049,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id === 0) { $this->env_filename = '.env'; - foreach ($sorted_environment_variables as $env) { - $envs->push($env->key.'='.$env->real_value); - } - // Add PORT if not exists, use the first port as default - if ($this->build_pack !== 'dockercompose') { - if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) { - $envs->push("PORT={$ports[0]}"); - } - } - // Add HOST if not exists - if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) { - $envs->push('HOST=0.0.0.0'); - } - + // Generate SERVICE_ variables first for dockercompose if ($this->build_pack === 'dockercompose') { $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); @@ -949,23 +1066,50 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); } } + + // Generate SERVICE_NAME for dockercompose services from processed compose + if ($this->application->settings->is_raw_compose_deployment_enabled) { + $dockerCompose = Yaml::parse($this->application->docker_compose_raw); + } else { + $dockerCompose = Yaml::parse($this->application->docker_compose); + } + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName); + } } - } else { - $this->env_filename = ".env-pr-$this->pull_request_id"; - foreach ($sorted_environment_variables_preview as $env) { + + // Filter runtime variables (only include variables that are available at runtime) + $runtime_environment_variables = $sorted_environment_variables->filter(function ($env) { + return $env->is_runtime; + }); + + // Sort runtime environment variables: those referencing SERVICE_ variables come after others + $runtime_environment_variables = $runtime_environment_variables->sortBy(function ($env) { + if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) { + return 2; + } + + return 1; + }); + + foreach ($runtime_environment_variables as $env) { $envs->push($env->key.'='.$env->real_value); } // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { - if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) { + if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) { $envs->push("PORT={$ports[0]}"); } } // Add HOST if not exists - if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) { + if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) { $envs->push('HOST=0.0.0.0'); } + } else { + $this->env_filename = '.env'; + // Generate SERVICE_ variables first for dockercompose preview if ($this->build_pack === 'dockercompose') { $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); @@ -982,44 +1126,121 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); } } + + // Generate SERVICE_NAME for dockercompose services + $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw); + $rawServices = data_get($rawDockerCompose, 'services', []); + foreach ($rawServices as $rawServiceName => $_) { + $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)); + } + } + + // Filter runtime variables for preview (only include variables that are available at runtime) + $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) { + return $env->is_runtime; + }); + + // Sort runtime environment variables: those referencing SERVICE_ variables come after others + $runtime_environment_variables_preview = $runtime_environment_variables_preview->sortBy(function ($env) { + if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) { + return 2; + } + + return 1; + }); + + foreach ($runtime_environment_variables_preview as $env) { + $envs->push($env->key.'='.$env->real_value); + } + // Add PORT if not exists, use the first port as default + if ($this->build_pack !== 'dockercompose') { + if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) { + $envs->push("PORT={$ports[0]}"); + } + } + // Add HOST if not exists + if ($this->application->environment_variables_preview->where('key', 'HOST')->isEmpty()) { + $envs->push('HOST=0.0.0.0'); } } if ($envs->isEmpty()) { - $this->env_filename = null; - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - $this->server = $this->build_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - } else { - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); + if ($this->env_filename) { + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + $this->server = $this->build_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } else { + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } } + $this->env_filename = null; } else { - $envs_base64 = base64_encode($envs->implode("\n")); + // For Nixpacks builds, we save the .env file AFTER the build to prevent + // runtime-only variables from being accessible during the build process + if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) { + $envs_base64 = base64_encode($envs->implode("\n")); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), + ], + + ); + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", + ] + ); + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", + ] + ); + } + } + } + $this->environment_variables = $envs; + } + + private function save_runtime_environment_variables() + { + // This method saves the .env file with runtime variables + // It should be called AFTER the build for Nixpacks to prevent runtime-only variables + // from being accessible during the build process + + if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) { + $envs_base64 = base64_encode($this->environment_variables->implode("\n")); + + // Write .env file to workdir (for container runtime) $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), ], - ); + + // Write .env file to configuration directory if ($this->use_build_server) { $this->server = $this->original_server; $this->execute_remote_command( @@ -1036,7 +1257,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } } - $this->environment_variables = $envs; } private function elixir_finetunes() @@ -1047,32 +1267,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $envType = 'environment_variables_preview'; } $mix_env = $this->application->{$envType}->where('key', 'MIX_ENV')->first(); - if ($mix_env) { - if ($mix_env->is_build_time === false) { - $this->application_deployment_queue->addLogEntry('MIX_ENV environment variable is not set as build time.', type: 'error'); - $this->application_deployment_queue->addLogEntry('Please set MIX_ENV environment variable to be build time variable if you facing any issues with the deployment.', type: 'error'); - } - } else { + if (! $mix_env) { $this->application_deployment_queue->addLogEntry('MIX_ENV environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add MIX_ENV environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } $secret_key_base = $this->application->{$envType}->where('key', 'SECRET_KEY_BASE')->first(); - if ($secret_key_base) { - if ($secret_key_base->is_build_time === false) { - $this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable is not set as build time.', type: 'error'); - $this->application_deployment_queue->addLogEntry('Please set SECRET_KEY_BASE environment variable to be build time variable if you facing any issues with the deployment.', type: 'error'); - } - } else { + if (! $secret_key_base) { $this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add SECRET_KEY_BASE environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } $database_url = $this->application->{$envType}->where('key', 'DATABASE_URL')->first(); - if ($database_url) { - if ($database_url->is_build_time === false) { - $this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable is not set as build time.', type: 'error'); - $this->application_deployment_queue->addLogEntry('Please set DATABASE_URL environment variable to be build time variable if you facing any issues with the deployment.', type: 'error'); - } - } else { + if (! $database_url) { $this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add DATABASE_URL environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } @@ -1092,7 +1297,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $nixpacks_php_fallback_path = new EnvironmentVariable; $nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH'; $nixpacks_php_fallback_path->value = '/index.php'; - $nixpacks_php_fallback_path->is_build_time = false; $nixpacks_php_fallback_path->resourceable_id = $this->application->id; $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application'; $nixpacks_php_fallback_path->save(); @@ -1101,7 +1305,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $nixpacks_php_root_dir = new EnvironmentVariable; $nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR'; $nixpacks_php_root_dir->value = '/app/public'; - $nixpacks_php_root_dir->is_build_time = false; $nixpacks_php_root_dir->resourceable_id = $this->application->id; $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application'; $nixpacks_php_root_dir->save(); @@ -1112,6 +1315,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function rolling_update() { + $this->checkForCancellation(); if ($this->server->isSwarm()) { $this->application_deployment_queue->addLogEntry('Rolling update started.'); $this->execute_remote_command( @@ -1271,8 +1475,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->add_build_env_variables_to_dockerfile(); } $this->build_image(); + // For Nixpacks, save runtime environment variables AFTER the build + if ($this->application->build_pack === 'nixpacks') { + $this->save_runtime_environment_variables(); + } $this->push_to_docker_registry(); - // $this->stop_running_container(); $this->rolling_update(); } @@ -1308,22 +1515,26 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function prepare_builder_image() { + $this->checkForCancellation(); $settings = instanceSettings(); $helperImage = config('constants.coolify.helper_image'); $helperImage = "{$helperImage}:{$settings->helper_version}"; // Get user home directory $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); + + $env_flags = $this->generate_docker_env_flags_for_secrets(); + if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); } - $runCommand = "docker run -d --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { if ($this->dockerConfigFileExists === 'OK') { - $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { - $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } } $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage."); @@ -1428,6 +1639,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->pull_request_id !== 0) { $local_branch = "pull/{$this->pull_request_id}/head"; } + // Build an exact refspec for ls-remote so we don't match similarly named branches (e.g., changeset-release/main) + if ($this->pull_request_id === 0) { + $lsRemoteRef = "refs/heads/{$local_branch}"; + } else { + if ($this->git_type === 'github' || $this->git_type === 'gitea') { + $lsRemoteRef = "refs/pull/{$this->pull_request_id}/head"; + } elseif ($this->git_type === 'gitlab') { + $lsRemoteRef = "refs/merge-requests/{$this->pull_request_id}/head"; + } else { + // Fallback to the original value if provider-specific ref is unknown + $lsRemoteRef = $local_branch; + } + } $private_key = data_get($this->application, 'private_key.private_key'); if ($private_key) { $private_key = base64_encode($private_key); @@ -1442,7 +1666,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), ], [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$local_branch}"), + executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), 'hidden' => true, 'save' => 'git_commit_sha', ] @@ -1450,7 +1674,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$local_branch}"), + executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"), 'hidden' => true, 'save' => 'git_commit_sha', ], @@ -1518,6 +1742,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { $nixpacks_command = $this->nixpacks_build_cmd(); $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command"); + $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true], [executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true], @@ -1537,6 +1762,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $parsed = Toml::Parse($this->nixpacks_plan); // Do any modifications here + // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile $this->generate_env_variables(); $merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args); $aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []); @@ -1609,6 +1835,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + // Add COOLIFY_* environment variables to Nixpacks build context + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { + $this->env_nixpacks_args->push("--env {$key}={$value}"); + }); + $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } @@ -1702,8 +1934,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->env_args = collect([]); $this->env_args->put('SOURCE_COMMIT', $this->commit); $coolify_envs = $this->generate_coolify_env_variables(); + + // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { - foreach ($this->application->build_environment_variables as $env) { + // Get environment variables that are marked as available during build + $envs = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + + foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); if (str($env->real_value)->startsWith('$')) { @@ -1723,7 +1963,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } } else { - foreach ($this->application->build_environment_variables_preview as $env) { + // Get preview environment variables that are marked as available during build + $envs = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + + foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); if (str($env->real_value)->startsWith('$')) { @@ -1747,13 +1993,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function generate_compose_file() { + $this->checkForCancellation(); $this->create_workdir(); $ports = $this->application->main_port(); $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->application->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); - // $environment_variables = $this->generate_environment_variables($ports); - $this->save_environment_variables(); + $this->generate_runtime_environment_variables(); if (data_get($this->application, 'custom_labels')) { $this->application->parseContainerLabels(); $labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels))); @@ -1822,7 +2068,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ], ], ]; - if (! is_null($this->env_filename)) { + if (filled($this->env_filename)) { $docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename]; } $docker_compose['services'][$this->container_name]['healthcheck'] = [ @@ -1993,7 +2239,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $volume_name = $persistentStorage->name; } if ($this->pull_request_id !== 0) { - $volume_name = $volume_name.'-pr-'.$this->pull_request_id; + $volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id); } $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } @@ -2011,7 +2257,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $name = $persistentStorage->name; if ($this->pull_request_id !== 0) { - $name = $name.'-pr-'.$this->pull_request_id; + $name = addPreviewDeploymentSuffix($name, $this->pull_request_id); } $local_persistent_volumes_names[$name] = [ @@ -2059,16 +2305,74 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } + private function build_static_image() + { + $this->application_deployment_queue->addLogEntry('----------------------------------------'); + $this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.'); + if ($this->application->static_image) { + $this->pull_latest_image($this->application->static_image); + } + $dockerfile = base64_encode("FROM {$this->application->static_image} + WORKDIR /usr/share/nginx/html/ + LABEL coolify.deploymentId={$this->deployment_uuid} + COPY . . + RUN rm -f /usr/share/nginx/html/nginx.conf + RUN rm -f /usr/share/nginx/html/Dockerfile + RUN rm -f /usr/share/nginx/html/docker-compose.yaml + RUN rm -f /usr/share/nginx/html/.env + COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + if ($this->application->settings->is_spa) { + $nginx_config = base64_encode(defaultNginxConfiguration('spa')); + } else { + $nginx_config = base64_encode(defaultNginxConfiguration()); + } + } + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"; + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + $this->application_deployment_queue->addLogEntry('Building docker image completed.'); + } + private function build_image() { - // Add Coolify related variables to the build args - $this->environment_variables->filter(function ($key, $value) { - return str($key)->startsWith('COOLIFY_'); - })->each(function ($key, $value) { - $this->build_args->push("--build-arg '{$key}'"); - }); + // Add Coolify related variables to the build args/secrets + if ($this->dockerBuildkitSupported) { + // Coolify variables are already included in the secrets from generate_build_env_variables + // build_secrets is already a string at this point + } else { + // Traditional build args approach + $this->environment_variables->filter(function ($key, $value) { + return str($key)->startsWith('COOLIFY_'); + })->each(function ($key, $value) { + $this->build_args->push("--build-arg '{$key}'"); + }); - $this->build_args = $this->build_args->implode(' '); + $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection + ? $this->build_args->implode(' ') + : (string) $this->build_args; + } $this->application_deployment_queue->addLogEntry('----------------------------------------'); if ($this->disableBuildCache) { @@ -2081,100 +2385,114 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.'); } - if ($this->application->settings->is_static || $this->application->build_pack === 'static') { + if ($this->application->settings->is_static) { if ($this->application->static_image) { $this->pull_latest_image($this->application->static_image); $this->application_deployment_queue->addLogEntry('Continuing with the building process.'); } - if ($this->application->build_pack === 'static') { - $dockerfile = base64_encode("FROM {$this->application->static_image} -WORKDIR /usr/share/nginx/html/ -LABEL coolify.deploymentId={$this->deployment_uuid} -COPY . . -RUN rm -f /usr/share/nginx/html/nginx.conf -RUN rm -f /usr/share/nginx/html/Dockerfile -RUN rm -f /usr/share/nginx/html/docker-compose.yaml -RUN rm -f /usr/share/nginx/html/.env -COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); - } else { - if ($this->application->settings->is_spa) { - $nginx_config = base64_encode(defaultNginxConfiguration('spa')); + if ($this->application->build_pack === 'nixpacks') { + $this->nixpacks_plan = base64_encode($this->nixpacks_plan); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]); + if ($this->force_rebuild) { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), + 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, + ]); + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; } else { - $nginx_config = base64_encode(defaultNginxConfiguration()); + $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; + } + } else { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), + 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, + ]); + if ($this->dockerBuildkitSupported) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; + } else { + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; } } - } else { - if ($this->application->build_pack === 'nixpacks') { - $this->nixpacks_plan = base64_encode($this->nixpacks_plan); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]); - if ($this->force_rebuild) { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), - 'hidden' => true, - ]); - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->build_image_name} {$this->workdir}"; - } else { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), - 'hidden' => true, - ]); - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->build_image_name} {$this->workdir}"; - } - $base64_build_command = base64_encode($build_command); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), - 'hidden' => true, - ] - ); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); + } else { + // Dockerfile buildpack + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; + } else { + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; + } } else { + // Traditional build with args if ($this->force_rebuild) { $build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; - $base64_build_command = base64_encode($build_command); } else { $build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; - $base64_build_command = base64_encode($build_command); } - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), - 'hidden' => true, - ] - ); } - $dockerfile = base64_encode("FROM {$this->application->static_image} + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + } + $dockerfile = base64_encode("FROM {$this->application->static_image} WORKDIR /usr/share/nginx/html/ LABEL coolify.deploymentId={$this->deployment_uuid} COPY --from=$this->build_image_name /app/{$this->application->publish_directory} . COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + if ($this->application->settings->is_spa) { + $nginx_config = base64_encode(defaultNginxConfiguration('spa')); } else { - if ($this->application->settings->is_spa) { - $nginx_config = base64_encode(defaultNginxConfiguration('spa')); - } else { - $nginx_config = base64_encode(defaultNginxConfiguration()); - } + $nginx_config = base64_encode(defaultNginxConfiguration()); } } $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; @@ -2202,10 +2520,22 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { // Pure Dockerfile based deployment if ($this->application->dockerfile) { - if ($this->force_rebuild) { - $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } else { - $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + // Traditional build with args + if ($this->force_rebuild) { + $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2230,14 +2560,34 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"), 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, ]); - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + } } else { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"), 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, ]); - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2256,13 +2606,24 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ); $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); } else { - if ($this->force_rebuild) { - $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; - $base64_build_command = base64_encode($build_command); + // Dockerfile buildpack + if ($this->dockerBuildkitSupported) { + // Use BuildKit with secrets + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } else { - $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; - $base64_build_command = base64_encode($build_command); + // Traditional build with args + if ($this->force_rebuild) { + $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } + $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), @@ -2306,7 +2667,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id); if ($this->pull_request_id === 0) { $containers = $containers->filter(function ($container) { - return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== $this->container_name.'-pr-'.$this->pull_request_id; + return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id); }); } $containers->each(function ($container) { @@ -2358,43 +2719,423 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $variables = collect([])->merge($this->env_args); } - $this->build_args = $variables->map(function ($value, $key) { - $value = escapeshellarg($value); + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + $this->generate_build_secrets($variables); + $this->build_args = ''; + } else { + $secrets_hash = ''; + if ($variables->isNotEmpty()) { + $secrets_hash = $this->generate_secrets_hash($variables); + } - return "--build-arg {$key}={$value}"; - }); + $this->build_args = $variables->map(function ($value, $key) { + $value = escapeshellarg($value); + + return "--build-arg {$key}={$value}"; + }); + + if ($secrets_hash) { + $this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); + } + } + } + + private function generate_docker_env_flags_for_secrets() + { + // Only generate env flags if build secrets are enabled + if (! $this->application->settings->use_build_secrets) { + return ''; + } + + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); + + if ($variables->isEmpty()) { + return ''; + } + + $secrets_hash = $this->generate_secrets_hash($variables); + $env_flags = $variables + ->map(function ($env) { + $escaped_value = escapeshellarg($env->real_value); + + return "-e {$env->key}={$escaped_value}"; + }) + ->implode(' '); + + $env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"; + + return $env_flags; + } + + private function generate_build_secrets(Collection $variables) + { + if ($variables->isEmpty()) { + $this->build_secrets = ''; + + return; + } + + $this->build_secrets = $variables + ->map(function ($value, $key) { + return "--secret id={$key},env={$key}"; + }) + ->implode(' '); + + $this->build_secrets .= ' --secret id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; + } + + private function generate_secrets_hash($variables) + { + if (! $this->secrets_hash_key) { + $this->secrets_hash_key = bin2hex(random_bytes(32)); + } + + if ($variables instanceof Collection) { + $secrets_string = $variables + ->mapWithKeys(function ($value, $key) { + return [$key => $value]; + }) + ->sortKeys() + ->map(function ($value, $key) { + return "{$key}={$value}"; + }) + ->implode('|'); + } else { + $secrets_string = $variables + ->map(function ($env) { + return "{$env->key}={$env->real_value}"; + }) + ->sort() + ->implode('|'); + } + + return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key); } private function add_build_env_variables_to_dockerfile() { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), - 'hidden' => true, - 'save' => 'dockerfile', - ]); - $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - if ($this->pull_request_id === 0) { - foreach ($this->application->build_environment_variables as $env) { - if (data_get($env, 'is_multiline') === true) { - $dockerfile->splice(1, 0, "ARG {$env->key}"); - } else { - $dockerfile->splice(1, 0, "ARG {$env->key}={$env->real_value}"); + if ($this->dockerBuildkitSupported) { + // We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets + } else { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), + 'hidden' => true, + 'save' => 'dockerfile', + ]); + $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); + + if ($this->pull_request_id === 0) { + // Only add environment variables that are available during build + $envs = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $dockerfile->splice(1, 0, ["ARG {$env->key}"]); + } else { + $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); + } + } + } else { + // Only add preview environment variables that are available during build + $envs = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $dockerfile->splice(1, 0, ["ARG {$env->key}"]); + } else { + $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); + } } } - } else { - foreach ($this->application->build_environment_variables_preview as $env) { - if (data_get($env, 'is_multiline') === true) { - $dockerfile->splice(1, 0, "ARG {$env->key}"); - } else { - $dockerfile->splice(1, 0, "ARG {$env->key}={$env->real_value}"); + + if ($envs->isNotEmpty()) { + $secrets_hash = $this->generate_secrets_hash($envs); + $dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]); + } + + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), + 'hidden' => true, + ]); + } + } + + private function modify_dockerfile_for_secrets($dockerfile_path) + { + // Only process if build secrets are enabled and we have secrets to mount + if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) { + return; + } + + // Read the Dockerfile + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$dockerfile_path}"), + 'hidden' => true, + 'save' => 'dockerfile_content', + ]); + + $dockerfile = str($this->saved_outputs->get('dockerfile_content'))->trim()->explode("\n"); + + // Add BuildKit syntax directive if not present + if (! str_starts_with($dockerfile->first(), '# syntax=')) { + $dockerfile->prepend('# syntax=docker/dockerfile:1'); + } + + // Get environment variables for secrets + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); + + if ($variables->isEmpty()) { + return; + } + + // Generate mount strings for all secrets + $mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' '); + + // Add mount for the secrets hash to ensure cache invalidation + $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; + + $modified = false; + $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { + $trimmed = ltrim($line); + + // Skip lines that already have secret mounts or are not RUN commands + if (str_contains($line, '--mount=type=secret') || ! str_starts_with($trimmed, 'RUN')) { + return $line; + } + + // Add mount strings to RUN command + $originalCommand = trim(substr($trimmed, 3)); + $modified = true; + + return "RUN {$mountStrings} {$originalCommand}"; + }); + + if ($modified) { + // Write the modified Dockerfile back + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"), + 'hidden' => true, + ]); + + $this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets.'); + } + } + + private function modify_dockerfiles_for_compose($composeFile) + { + if ($this->application->build_pack !== 'dockercompose') { + return; + } + + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get() + : $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + + if ($variables->isEmpty()) { + $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.'); + + return; + } + + $services = data_get($composeFile, 'services', []); + + foreach ($services as $serviceName => $service) { + if (! isset($service['build'])) { + continue; + } + + $context = '.'; + $dockerfile = 'Dockerfile'; + + if (is_string($service['build'])) { + $context = $service['build']; + } elseif (is_array($service['build'])) { + $context = data_get($service['build'], 'context', '.'); + $dockerfile = data_get($service['build'], 'dockerfile', 'Dockerfile'); + } + + $dockerfilePath = rtrim($context, '/').'/'.ltrim($dockerfile, '/'); + if (str_starts_with($dockerfilePath, './')) { + $dockerfilePath = substr($dockerfilePath, 2); + } + if (str_starts_with($dockerfilePath, '/')) { + $dockerfilePath = substr($dockerfilePath, 1); + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/{$dockerfilePath} && echo 'exists' || echo 'not found'"), + 'hidden' => true, + 'save' => 'dockerfile_check_'.$serviceName, + ]); + + if (str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString() !== 'exists') { + $this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection."); + + continue; + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/{$dockerfilePath}"), + 'hidden' => true, + 'save' => 'dockerfile_content_'.$serviceName, + ]); + + $dockerfileContent = $this->saved_outputs->get('dockerfile_content_'.$serviceName); + if (! $dockerfileContent) { + continue; + } + + $dockerfile_lines = collect(str($dockerfileContent)->trim()->explode("\n")); + + $fromIndices = []; + $dockerfile_lines->each(function ($line, $index) use (&$fromIndices) { + if (str($line)->trim()->startsWith('FROM')) { + $fromIndices[] = $index; + } + }); + + if (empty($fromIndices)) { + $this->application_deployment_queue->addLogEntry("No FROM instruction found in Dockerfile for service {$serviceName}, skipping."); + + continue; + } + + $isMultiStage = count($fromIndices) > 1; + + $argsToAdd = collect([]); + foreach ($variables as $env) { + $argsToAdd->push("ARG {$env->key}"); + } + + ray($argsToAdd); + if ($argsToAdd->isEmpty()) { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add."); + + continue; + } + + $totalAdded = 0; + $offset = 0; + + foreach ($fromIndices as $stageIndex => $fromIndex) { + $adjustedIndex = $fromIndex + $offset; + + $stageStart = $adjustedIndex + 1; + $stageEnd = isset($fromIndices[$stageIndex + 1]) + ? $fromIndices[$stageIndex + 1] + $offset + : $dockerfile_lines->count(); + + $existingStageArgs = collect([]); + for ($i = $stageStart; $i < $stageEnd; $i++) { + $line = $dockerfile_lines->get($i); + if (! $line || ! str($line)->trim()->startsWith('ARG')) { + break; + } + $parts = explode(' ', trim($line), 2); + if (count($parts) >= 2) { + $argPart = $parts[1]; + $keyValue = explode('=', $argPart, 2); + $existingStageArgs->push($keyValue[0]); + } + } + + $stageArgsToAdd = $argsToAdd->filter(function ($arg) use ($existingStageArgs) { + $key = str($arg)->after('ARG ')->trim()->toString(); + + return ! $existingStageArgs->contains($key); + }); + + if ($stageArgsToAdd->isNotEmpty()) { + $dockerfile_lines->splice($adjustedIndex + 1, 0, $stageArgsToAdd->toArray()); + $totalAdded += $stageArgsToAdd->count(); + $offset += $stageArgsToAdd->count(); + } + } + + if ($totalAdded > 0) { + $dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}/{$dockerfilePath} > /dev/null"), + 'hidden' => true, + ]); + + $stageInfo = $isMultiStage ? ' (multi-stage build, added to '.count($fromIndices).' stages)' : ''; + $this->application_deployment_queue->addLogEntry("Added {$totalAdded} ARG declarations to Dockerfile for service {$serviceName}{$stageInfo}."); + } else { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist."); + } + + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}"; + $this->modify_dockerfile_for_secrets($fullDockerfilePath); + $this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets."); + } + } + } + + private function add_build_secrets_to_compose($composeFile) + { + // Get environment variables for secrets + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + + if ($variables->isEmpty()) { + return $composeFile; + } + + $secrets = []; + foreach ($variables as $env) { + $secrets[$env->key] = [ + 'environment' => $env->key, + ]; + } + + $services = data_get($composeFile, 'services', []); + foreach ($services as $serviceName => &$service) { + if (isset($service['build'])) { + if (is_string($service['build'])) { + $service['build'] = [ + 'context' => $service['build'], + ]; + } + if (! isset($service['build']['secrets'])) { + $service['build']['secrets'] = []; + } + foreach ($variables as $env) { + if (! in_array($env->key, $service['build']['secrets'])) { + $service['build']['secrets'][] = $env->key; + } } } } - $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), - 'hidden' => true, - ]); + + $composeFile['services'] = $services; + $existingSecrets = data_get($composeFile, 'secrets', []); + if ($existingSecrets instanceof \Illuminate\Support\Collection) { + $existingSecrets = $existingSecrets->toArray(); + } + $composeFile['secrets'] = array_replace($existingSecrets, $secrets); + + $this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file (using environment variables).'); + + return $composeFile; } private function run_pre_deployment_command() @@ -2462,9 +3203,22 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?'); } + /** + * Check if the deployment was cancelled and abort if it was + */ + private function checkForCancellation(): void + { + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); + throw new \RuntimeException('Deployment cancelled by user', 69420); + } + } + private function next(string $status) { - queue_next_deployment($this->application); + // Refresh to get latest status + $this->application_deployment_queue->refresh(); // Never allow changing status from FAILED or CANCELLED_BY_USER to anything else if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) { @@ -2473,14 +3227,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); return; } if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { - return; + // Job was cancelled, stop execution + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); + throw new \RuntimeException('Deployment cancelled by user', 69420); } $this->application_deployment_queue->update([ 'status' => $status, ]); + queue_next_deployment($this->application); + if ($status === ApplicationDeploymentStatus::FINISHED->value) { + ray($this->application->team()->id); + event(new ApplicationConfigurationChanged($this->application->team()->id)); + if (! $this->only_this_server) { $this->deploy_to_additional_destinations(); } @@ -2500,8 +3261,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $code = $exception->getCode(); if ($code !== 69420) { // 69420 means failed to push the image to the registry, so we don't need to remove the new version as it is the currently running one - if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) { - // do not remove already running container + if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0) { + // do not remove already running container for PR deployments } else { $this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr'); $this->execute_remote_command( diff --git a/app/Jobs/CleanupInstanceStuffsJob.php b/app/Jobs/CleanupInstanceStuffsJob.php index 60ae58489..011c58639 100644 --- a/app/Jobs/CleanupInstanceStuffsJob.php +++ b/app/Jobs/CleanupInstanceStuffsJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\TeamInvitation; +use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -30,6 +31,7 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho { try { $this->cleanupInvitationLink(); + $this->cleanupExpiredEmailChangeRequests(); } catch (\Throwable $e) { Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage()); } @@ -42,4 +44,15 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho $item->isValid(); } } + + private function cleanupExpiredEmailChangeRequests() + { + User::whereNotNull('email_change_code_expires_at') + ->where('email_change_code_expires_at', '<', now()) + ->update([ + 'pending_email' => null, + 'email_change_code' => null, + 'email_change_code_expires_at' => null, + ]); + } } diff --git a/app/Jobs/ContainerStatusJob.php b/app/Jobs/ContainerStatusJob.php deleted file mode 100644 index 22ae06ebd..000000000 --- a/app/Jobs/ContainerStatusJob.php +++ /dev/null @@ -1,31 +0,0 @@ -server); - } -} diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 428cdfda2..6ac9ae1e6 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -54,6 +54,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue public ?string $backup_output = null; + public ?string $error_output = null; + + public bool $s3_uploaded = false; + public ?string $postgres_password = null; public ?string $mongo_root_username = null; @@ -351,6 +355,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $size = $this->calculate_size(); if ($this->backup->save_s3) { $this->upload_to_s3(); + + // If local backup is disabled, delete the local file immediately after S3 upload + if ($this->backup->disable_local_backup) { + deleteBackupsLocally($this->backup_location, $this->server); + } } $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); @@ -361,15 +370,34 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'size' => $size, ]); } catch (\Throwable $e) { - if ($this->backup_log) { - $this->backup_log->update([ - 'status' => 'failed', - 'message' => $this->backup_output, - 'size' => $size, - 'filename' => null, - ]); + // Check if backup actually failed or if it's just a post-backup issue + $actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3; + + if ($actualBackupFailed || $size === 0) { + // Real backup failure + if ($this->backup_log) { + $this->backup_log->update([ + 'status' => 'failed', + 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(), + 'size' => $size, + 'filename' => null, + ]); + } + $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database)); + } else { + // Backup succeeded but post-processing failed (cleanup, notification, etc.) + if ($this->backup_log) { + $this->backup_log->update([ + 'status' => 'success', + 'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(), + 'size' => $size, + ]); + } + // Send success notification since the backup itself succeeded + $this->team->notify(new BackupSuccess($this->backup, $this->database, $database)); + // Log the post-backup issue + ray('Post-backup operation failed but backup was successful: '.$e->getMessage()); } - $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->backup_output, $database)); } } if ($this->backup_log && $this->backup_log->status === 'success') { @@ -440,7 +468,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -466,7 +494,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -486,7 +514,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -506,7 +534,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->backup_output = null; } } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->add_to_error_output($e->getMessage()); throw $e; } } @@ -520,6 +548,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } + private function add_to_error_output($output): void + { + if ($this->error_output) { + $this->error_output = $this->error_output."\n".$output; + } else { + $this->error_output = $output; + } + } + private function calculate_size() { return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false); @@ -561,13 +598,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } else { $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}"; } - $commands[] = "docker exec backup-of-{$this->backup->uuid} mc config host add temporary {$endpoint} $key \"$secret\""; + $commands[] = "docker exec backup-of-{$this->backup->uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\""; $commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; instant_remote_process($commands, $this->server); - $this->add_to_backup_output('Uploaded to S3.'); + $this->s3_uploaded = true; } catch (\Throwable $e) { - $this->add_to_backup_output($e->getMessage()); + $this->s3_uploaded = false; + $this->add_to_error_output($e->getMessage()); throw $e; } finally { $command = "docker rm -f backup-of-{$this->backup->uuid}"; diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 2750110f2..b9fbebcc9 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -32,10 +32,10 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue public function __construct( public Application|ApplicationPreview|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource, - public bool $deleteConfigurations = true, public bool $deleteVolumes = true, - public bool $dockerCleanup = true, - public bool $deleteConnectedNetworks = true + public bool $deleteConnectedNetworks = true, + public bool $deleteConfigurations = true, + public bool $dockerCleanup = true ) { $this->onQueue('high'); } @@ -52,7 +52,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue switch ($this->resource->type()) { case 'application': - StopApplication::run($this->resource, previewDeployments: true); + StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup); break; case 'standalone-postgresql': case 'standalone-redis': @@ -62,11 +62,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue case 'standalone-keydb': case 'standalone-dragonfly': case 'standalone-clickhouse': - StopDatabase::run($this->resource, true); + StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup); break; case 'service': - StopService::run($this->resource, true); - DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks); + StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup); + DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup); return; } @@ -78,7 +78,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $this->resource->deleteVolumes(); $this->resource->persistentStorages()->delete(); } - $this->resource->fileStorages()->delete(); + $this->resource->fileStorages()->delete(); // these are file mounts which should probably have their own flag $isDatabase = $this->resource instanceof StandalonePostgresql || $this->resource instanceof StandaloneRedis @@ -106,7 +106,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue if ($this->dockerCleanup) { $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); if ($server) { - CleanupDocker::dispatch($server, true); + CleanupDocker::dispatch($server, false, false); } } Artisan::queue('cleanup:stucked-resources'); diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 519728ab0..f3f3a2ae4 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -34,7 +34,12 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue return [(new WithoutOverlapping('docker-cleanup-'.$this->server->uuid))->expireAfter(600)->dontRelease()]; } - public function __construct(public Server $server, public bool $manualCleanup = false) {} + public function __construct( + public Server $server, + public bool $manualCleanup = false, + public bool $deleteUnusedVolumes = false, + public bool $deleteUnusedNetworks = false + ) {} public function handle(): void { @@ -50,7 +55,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue $this->usageBefore = $this->server->getDiskUsage(); if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) { - $cleanup_log = CleanupDocker::run(server: $this->server); + $cleanup_log = CleanupDocker::run( + server: $this->server, + deleteUnusedVolumes: $this->deleteUnusedVolumes, + deleteUnusedNetworks: $this->deleteUnusedNetworks + ); $usageAfter = $this->server->getDiskUsage(); $message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.'; @@ -67,7 +76,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue } if (str($this->usageBefore)->isEmpty() || $this->usageBefore === null || $this->usageBefore === 0) { - $cleanup_log = CleanupDocker::run(server: $this->server); + $cleanup_log = CleanupDocker::run( + server: $this->server, + deleteUnusedVolumes: $this->deleteUnusedVolumes, + deleteUnusedNetworks: $this->deleteUnusedNetworks + ); $message = 'Docker cleanup job executed successfully, but no disk usage could be determined.'; $this->execution_log->update([ @@ -81,7 +94,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue } if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) { - $cleanup_log = CleanupDocker::run(server: $this->server); + $cleanup_log = CleanupDocker::run( + server: $this->server, + deleteUnusedVolumes: $this->deleteUnusedVolumes, + deleteUnusedNetworks: $this->deleteUnusedNetworks + ); $usageAfter = $this->server->getDiskUsage(); $diskSaved = $this->usageBefore - $usageAfter; diff --git a/app/Jobs/PullChangelog.php b/app/Jobs/PullChangelog.php new file mode 100644 index 000000000..052e6d557 --- /dev/null +++ b/app/Jobs/PullChangelog.php @@ -0,0 +1,126 @@ +onQueue('high'); + } + + public function handle(): void + { + try { + // Fetch from CDN instead of GitHub API to avoid rate limits + $cdnUrl = config('constants.coolify.releases_url'); + + $response = Http::retry(3, 1000) + ->timeout(30) + ->get($cdnUrl); + + if ($response->successful()) { + $releases = $response->json(); + + // Limit to 10 releases for processing (same as before) + $releases = array_slice($releases, 0, 10); + + $changelog = $this->transformReleasesToChangelog($releases); + + // Group entries by month and save them + $this->saveChangelogEntries($changelog); + } else { + // Log error instead of sending notification + Log::error('PullChangelogFromGitHub: Failed to fetch from CDN', [ + 'status' => $response->status(), + 'url' => $cdnUrl, + ]); + } + } catch (\Throwable $e) { + // Log error instead of sending notification + Log::error('PullChangelogFromGitHub: Exception occurred', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + private function transformReleasesToChangelog(array $releases): array + { + $entries = []; + + foreach ($releases as $release) { + // Skip drafts and pre-releases if desired + if ($release['draft']) { + continue; + } + + $publishedAt = Carbon::parse($release['published_at']); + + $entry = [ + 'tag_name' => $release['tag_name'], + 'title' => $release['name'] ?: $release['tag_name'], + 'content' => $release['body'] ?: 'No release notes available.', + 'published_at' => $publishedAt->toISOString(), + ]; + + $entries[] = $entry; + } + + return $entries; + } + + private function saveChangelogEntries(array $entries): void + { + // Create changelogs directory if it doesn't exist + $changelogsDir = base_path('changelogs'); + if (! File::exists($changelogsDir)) { + File::makeDirectory($changelogsDir, 0755, true); + } + + // Group entries by year-month + $groupedEntries = []; + foreach ($entries as $entry) { + $date = Carbon::parse($entry['published_at']); + $monthKey = $date->format('Y-m'); + + if (! isset($groupedEntries[$monthKey])) { + $groupedEntries[$monthKey] = []; + } + + $groupedEntries[$monthKey][] = $entry; + } + + // Save each month's entries to separate files + foreach ($groupedEntries as $month => $monthEntries) { + // Sort entries by published date (newest first) + usort($monthEntries, function ($a, $b) { + return Carbon::parse($b['published_at'])->timestamp - Carbon::parse($a['published_at'])->timestamp; + }); + + $monthData = [ + 'entries' => $monthEntries, + 'last_updated' => now()->toISOString(), + ]; + + $filePath = base_path("changelogs/{$month}.json"); + File::put($filePath, json_encode($monthData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + + } +} diff --git a/app/Jobs/PullTemplatesFromCDN.php b/app/Jobs/PullTemplatesFromCDN.php index 9a4c991bc..7e6b2e21a 100644 --- a/app/Jobs/PullTemplatesFromCDN.php +++ b/app/Jobs/PullTemplatesFromCDN.php @@ -31,7 +31,7 @@ class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue $response = Http::retry(3, 1000)->get(config('constants.services.official')); if ($response->successful()) { $services = $response->json(); - File::put(base_path('templates/service-templates.json'), json_encode($services)); + File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services)); } else { send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body()); } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 3e3aa1eb7..7726c2c73 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -65,6 +65,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced public Collection $foundApplicationPreviewsIds; + public Collection $applicationContainerStatuses; + public bool $foundProxy = false; public bool $foundLogDrainContainer = false; @@ -87,6 +89,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->foundServiceApplicationIds = collect(); $this->foundApplicationPreviewsIds = collect(); $this->foundServiceDatabaseIds = collect(); + $this->applicationContainerStatuses = collect(); $this->allApplicationIds = collect(); $this->allDatabaseUuids = collect(); $this->allTcpProxyUuids = collect(); @@ -155,7 +158,14 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) { $this->foundApplicationIds->push($applicationId); } - $this->updateApplicationStatus($applicationId, $containerStatus); + // Store container status for aggregation + if (! $this->applicationContainerStatuses->has($applicationId)) { + $this->applicationContainerStatuses->put($applicationId, collect()); + } + $containerName = $labels->get('com.docker.compose.service'); + if ($containerName) { + $this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus); + } } else { $previewKey = $applicationId.':'.$pullRequestId; if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) { @@ -205,9 +215,86 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced $this->updateAdditionalServersStatus(); + // Aggregate multi-container application statuses + $this->aggregateMultiContainerStatuses(); + $this->checkLogDrainContainer(); } + private function aggregateMultiContainerStatuses() + { + if ($this->applicationContainerStatuses->isEmpty()) { + return; + } + + foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) { + $application = $this->applications->where('id', $applicationId)->first(); + if (! $application) { + continue; + } + + // Parse docker compose to check for excluded containers + $dockerComposeRaw = data_get($application, 'docker_compose_raw'); + $excludedContainers = collect(); + + if ($dockerComposeRaw) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $services = data_get($dockerCompose, 'services', []); + + foreach ($services as $serviceName => $serviceConfig) { + // Check if container should be excluded + $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false); + $restartPolicy = data_get($serviceConfig, 'restart', 'always'); + + if ($excludeFromHc || $restartPolicy === 'no') { + $excludedContainers->push($serviceName); + } + } + } catch (\Exception $e) { + // If we can't parse, treat all containers as included + } + } + + // Filter out excluded containers + $relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) { + return ! $excludedContainers->contains($containerName); + }); + + // If all containers are excluded, don't update status + if ($relevantStatuses->isEmpty()) { + continue; + } + + // Aggregate status: if any container is running, app is running + $hasRunning = false; + $hasUnhealthy = false; + + foreach ($relevantStatuses as $status) { + if (str($status)->contains('running')) { + $hasRunning = true; + if (str($status)->contains('unhealthy')) { + $hasUnhealthy = true; + } + } + } + + $aggregatedStatus = null; + if ($hasRunning) { + $aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)'; + } else { + // All containers are exited + $aggregatedStatus = 'exited (unhealthy)'; + } + + // Update application status with aggregated result + if ($aggregatedStatus && $application->status !== $aggregatedStatus) { + $application->status = $aggregatedStatus; + $application->save(); + } + } + } + private function updateApplicationStatus(string $applicationId, string $containerStatus) { $application = $this->applications->where('id', $applicationId)->first(); diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index b90e853fc..18ca0008c 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -4,6 +4,8 @@ namespace App\Jobs; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledTask; +use App\Models\Server; +use App\Models\Team; use Cron\CronExpression; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -12,6 +14,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; class ScheduledJobManager implements ShouldQueue @@ -77,6 +80,16 @@ class ScheduledJobManager implements ShouldQueue 'trace' => $e->getTraceAsString(), ]); } + + // Process Docker cleanups - don't let failures stop the job manager + try { + $this->processDockerCleanups(); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Failed to process docker cleanups', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } } private function processScheduledBackups(): void @@ -226,4 +239,75 @@ class ScheduledJobManager implements ShouldQueue return $cron->isDue($executionTime); } + + private function processDockerCleanups(): void + { + // Get all servers that need cleanup checks + $servers = $this->getServersForCleanup(); + + foreach ($servers as $server) { + try { + if (! $this->shouldProcessDockerCleanup($server)) { + continue; + } + + $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone')); + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } + + $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); + if (isset(VALID_CRON_STRINGS[$frequency])) { + $frequency = VALID_CRON_STRINGS[$frequency]; + } + + // Use the frozen execution time for consistent evaluation + if ($this->shouldRunNow($frequency, $serverTimezone)) { + DockerCleanupJob::dispatch( + $server, + false, + $server->settings->delete_unused_volumes, + $server->settings->delete_unused_networks + ); + } + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing docker cleanup', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'error' => $e->getMessage(), + ]); + } + } + } + + private function getServersForCleanup(): Collection + { + $query = Server::with('settings') + ->where('ip', '!=', '1.2.3.4'); + + if (isCloud()) { + $servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); + $own = Team::find(0)->servers()->with('settings')->get(); + + return $servers->merge($own); + } + + return $query->get(); + } + + private function shouldProcessDockerCleanup(Server $server): bool + { + if (! $server->isFunctional()) { + return false; + } + + // In cloud, check subscription status (except team 0) + if (isCloud() && $server->team_id !== 0) { + if (data_get($server->team->subscription, 'stripe_invoice_paid', false) === false) { + return false; + } + } + + return true; + } } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 6c0c017e7..609595356 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Events\ScheduledTaskDone; +use App\Exceptions\NonReportableException; use App\Models\Application; use App\Models\ScheduledTask; use App\Models\ScheduledTaskExecution; @@ -120,7 +121,7 @@ class ScheduledTaskJob implements ShouldQueue } // No valid container was found. - throw new \Exception('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); + throw new NonReportableException('ScheduledTaskJob failed: No valid container was found. Is the container name correct?'); } catch (\Throwable $e) { if ($this->task_log) { $this->task_log->update([ diff --git a/app/Jobs/ServerCheckNewJob.php b/app/Jobs/ServerCheckNewJob.php deleted file mode 100644 index 3e8e60a31..000000000 --- a/app/Jobs/ServerCheckNewJob.php +++ /dev/null @@ -1,34 +0,0 @@ -server); - ResourcesCheck::dispatch($this->server); - } catch (\Throwable $e) { - return handleError($e); - } - } -} diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php new file mode 100644 index 000000000..8b55434f6 --- /dev/null +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -0,0 +1,153 @@ +server->uuid))->expireAfter(45)->dontRelease()]; + } + + private function disableSshMux(): void + { + $configRepository = app(ConfigurationRepository::class); + $configRepository->disableSshMux(); + } + + public function handle() + { + try { + // Check if server is disabled + if ($this->server->settings->force_disabled) { + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + Log::debug('ServerConnectionCheck: Server is disabled', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + ]); + + return; + } + + // Temporarily disable mux if requested + if ($this->disableMux) { + $this->disableSshMux(); + } + + // Check basic connectivity first + $isReachable = $this->checkConnection(); + + if (! $isReachable) { + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + Log::warning('ServerConnectionCheck: Server not reachable', [ + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + 'server_ip' => $this->server->ip, + ]); + + return; + } + + // Server is reachable, check if Docker is available + $isUsable = $this->checkDockerAvailability(); + + $this->server->settings->update([ + 'is_reachable' => true, + 'is_usable' => $isUsable, + ]); + + } catch (\Throwable $e) { + $this->server->settings->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + throw $e; + } + } + + private function checkConnection(): bool + { + try { + // Use instant_remote_process with a simple command + // This will automatically handle mux, sudo, IPv6, Cloudflare tunnel, etc. + $output = instant_remote_process_with_timeout( + ['ls -la /'], + $this->server, + false // don't throw error + ); + + return $output !== null; + } catch (\Throwable $e) { + Log::debug('ServerConnectionCheck: Connection check failed', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + private function checkDockerAvailability(): bool + { + try { + // Use instant_remote_process to check Docker + // The function will automatically handle sudo for non-root users + $output = instant_remote_process_with_timeout( + ['docker version --format json'], + $this->server, + false // don't throw error + ); + + if ($output === null) { + return false; + } + + // Try to parse the JSON output to ensure Docker is really working + $output = trim($output); + if (! empty($output)) { + $dockerInfo = json_decode($output, true); + + return isset($dockerInfo['Server']['Version']); + } + + return false; + } catch (\Throwable $e) { + Log::debug('ServerConnectionCheck: Docker check failed', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Jobs/ServerResourceManager.php b/app/Jobs/ServerManagerJob.php similarity index 59% rename from app/Jobs/ServerResourceManager.php rename to app/Jobs/ServerManagerJob.php index cdf8efc56..043845c00 100644 --- a/app/Jobs/ServerResourceManager.php +++ b/app/Jobs/ServerManagerJob.php @@ -10,12 +10,12 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; -class ServerResourceManager implements ShouldQueue +class ServerManagerJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -28,6 +28,8 @@ class ServerResourceManager implements ShouldQueue private string $instanceTimezone; + private string $checkFrequency = '* * * * *'; + /** * Create a new job instance. */ @@ -36,22 +38,13 @@ class ServerResourceManager implements ShouldQueue $this->onQueue('high'); } - /** - * Get the middleware the job should pass through. - */ - public function middleware(): array - { - return [ - (new WithoutOverlapping('server-resource-manager')) - ->releaseAfter(60), - ]; - } - public function handle(): void { // Freeze the execution time at the start of the job $this->executionTime = Carbon::now(); - + if (isCloud()) { + $this->checkFrequency = '*/5 * * * *'; + } $this->settings = instanceSettings(); $this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone'); @@ -59,35 +52,17 @@ class ServerResourceManager implements ShouldQueue $this->instanceTimezone = config('app.timezone'); } - // Process server checks - don't let failures stop the job - try { - $this->processServerChecks(); - } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Failed to process server checks', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - } - } - - private function processServerChecks(): void - { + // Get all servers to process $servers = $this->getServers(); - foreach ($servers as $server) { - try { - $this->processServer($server); - } catch (\Exception $e) { - Log::channel('scheduled-errors')->error('Error processing server', [ - 'server_id' => $server->id, - 'server_name' => $server->name, - 'error' => $e->getMessage(), - ]); - } - } + // Dispatch ServerConnectionCheck for all servers efficiently + $this->dispatchConnectionChecks($servers); + + // Process server-specific scheduled tasks + $this->processScheduledTasks($servers); } - private function getServers() + private function getServers(): Collection { $allServers = Server::where('ip', '!=', '1.2.3.4'); @@ -101,19 +76,49 @@ class ServerResourceManager implements ShouldQueue } } - private function processServer(Server $server): void + private function dispatchConnectionChecks(Collection $servers): void { - $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - // Sentinel check + if ($this->shouldRunNow($this->checkFrequency)) { + $servers->each(function (Server $server) { + try { + ServerConnectionCheckJob::dispatch($server); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'error' => $e->getMessage(), + ]); + } + }); + } + } + + private function processScheduledTasks(Collection $servers): void + { + foreach ($servers as $server) { + try { + $this->processServerTasks($server); + } catch (\Exception $e) { + Log::channel('scheduled-errors')->error('Error processing server tasks', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + 'error' => $e->getMessage(), + ]); + } + } + } + + private function processServerTasks(Server $server): void + { + // Check if we should run sentinel-based checks $lastSentinelUpdate = $server->sentinel_updated_at; - if (Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($server->waitBeforeDoingSshCheck()))) { - // Dispatch ServerCheckJob if due - $checkFrequency = isCloud() ? '*/5 * * * *' : '* * * * *'; // Every 5 min for cloud, every minute for self-hosted - if ($this->shouldRunNow($checkFrequency, $serverTimezone)) { + $waitTime = $server->waitBeforeDoingSshCheck(); + $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime)); + + if ($sentinelOutOfSync) { + // Dispatch jobs if Sentinel is out of sync + if ($this->shouldRunNow($this->checkFrequency)) { ServerCheckJob::dispatch($server); } @@ -122,40 +127,43 @@ class ServerResourceManager implements ShouldQueue if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; } - if ($this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone)) { + $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency); + + if ($shouldRunStorageCheck) { ServerStorageCheckJob::dispatch($server); } } - // Dispatch DockerCleanupJob if due - $dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *'); - if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) { - $dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency]; - } - if ($this->shouldRunNow($dockerCleanupFrequency, $serverTimezone)) { - DockerCleanupJob::dispatch($server); + $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); } // Dispatch ServerPatchCheckJob if due (weekly) - if ($this->shouldRunNow('0 0 * * 0', $serverTimezone)) { // Weekly on Sunday at midnight + $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone); + + if ($shouldRunPatchCheck) { // Weekly on Sunday at midnight ServerPatchCheckJob::dispatch($server); } // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) - if ($server->isSentinelEnabled() && $this->shouldRunNow('0 0 * * *', $serverTimezone)) { + $isSentinelEnabled = $server->isSentinelEnabled(); + $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + + if ($shouldRestartSentinel) { dispatch(function () use ($server) { $server->restartContainer('coolify-sentinel'); }); } } - private function shouldRunNow(string $frequency, string $timezone): bool + private function shouldRunNow(string $frequency, ?string $timezone = null): bool { $cron = new CronExpression($frequency); // Use the frozen execution time, not the current time $baseTime = $this->executionTime ?? Carbon::now(); - $executionTime = $baseTime->copy()->setTimezone($timezone); + $executionTime = $baseTime->copy()->setTimezone($timezone ?? config('app.timezone')); return $cron->isDue($executionTime); } diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php index f1c5bc1a8..088b6c67d 100644 --- a/app/Jobs/StripeProcessJob.php +++ b/app/Jobs/StripeProcessJob.php @@ -58,7 +58,7 @@ class StripeProcessJob implements ShouldQueue case 'checkout.session.completed': $clientReferenceId = data_get($data, 'client_reference_id'); if (is_null($clientReferenceId)) { - send_internal_notification('Checkout session completed without client reference id.'); + // send_internal_notification('Checkout session completed without client reference id.'); break; } $userId = Str::before($clientReferenceId, ':'); @@ -68,7 +68,7 @@ class StripeProcessJob implements ShouldQueue $team = Team::find($teamId); $found = $team->members->where('id', $userId)->first(); if (! $found->isAdmin()) { - send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); + // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}, subscriptionid: {$subscriptionId}."); } $subscription = Subscription::where('team_id', $teamId)->first(); @@ -95,7 +95,7 @@ class StripeProcessJob implements ShouldQueue $customerId = data_get($data, 'customer'); $planId = data_get($data, 'lines.data.0.plan.id'); if (Str::contains($excludedPlans, $planId)) { - send_internal_notification('Subscription excluded.'); + // send_internal_notification('Subscription excluded.'); break; } $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); @@ -110,16 +110,38 @@ class StripeProcessJob implements ShouldQueue break; case 'invoice.payment_failed': $customerId = data_get($data, 'customer'); + $invoiceId = data_get($data, 'id'); + $paymentIntentId = data_get($data, 'payment_intent'); + $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); if (! $subscription) { - send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId); + // send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId); throw new \RuntimeException("No subscription found for customer: {$customerId}"); } $team = data_get($subscription, 'team'); if (! $team) { - send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId); + // send_internal_notification('invoice.payment_failed failed but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); } + + // Verify payment status with Stripe API before sending failure notification + if ($paymentIntentId) { + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + $paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId); + + if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) { + break; + } + + if (! $subscription->stripe_invoice_paid && $subscription->created_at->diffInMinutes(now()) < 5) { + SubscriptionInvoiceFailedJob::dispatch($team)->delay(now()->addSeconds(60)); + break; + } + } catch (\Exception $e) { + } + } + if (! $subscription->stripe_invoice_paid) { SubscriptionInvoiceFailedJob::dispatch($team); // send_internal_notification('Invoice payment failed: '.$customerId); @@ -129,11 +151,11 @@ class StripeProcessJob implements ShouldQueue $customerId = data_get($data, 'customer'); $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); if (! $subscription) { - send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId); + // send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId); throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}"); } if ($subscription->stripe_invoice_paid) { - send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId); + // send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId); return; } @@ -154,7 +176,7 @@ class StripeProcessJob implements ShouldQueue $team = Team::find($teamId); $found = $team->members->where('id', $userId)->first(); if (! $found->isAdmin()) { - send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); + // send_internal_notification("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); throw new \RuntimeException("User {$userId} is not an admin or owner of team {$team->id}, customerid: {$customerId}."); } $subscription = Subscription::where('team_id', $teamId)->first(); @@ -177,7 +199,7 @@ class StripeProcessJob implements ShouldQueue $subscriptionId = data_get($data, 'items.data.0.subscription') ?? data_get($data, 'id'); $planId = data_get($data, 'items.data.0.plan.id') ?? data_get($data, 'plan.id'); if (Str::contains($excludedPlans, $planId)) { - send_internal_notification('Subscription excluded.'); + // send_internal_notification('Subscription excluded.'); break; } $subscription = Subscription::where('stripe_customer_id', $customerId)->first(); @@ -194,7 +216,7 @@ class StripeProcessJob implements ShouldQueue 'stripe_invoice_paid' => false, ]); } else { - send_internal_notification('No subscription and team id found'); + // send_internal_notification('No subscription and team id found'); throw new \RuntimeException('No subscription and team id found'); } } @@ -230,7 +252,7 @@ class StripeProcessJob implements ShouldQueue $subscription->update([ 'stripe_past_due' => true, ]); - send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId); + // send_internal_notification('Past Due: '.$customerId.'Subscription ID: '.$subscriptionId); } } if ($status === 'unpaid') { @@ -238,13 +260,13 @@ class StripeProcessJob implements ShouldQueue $subscription->update([ 'stripe_invoice_paid' => false, ]); - send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId); + // send_internal_notification('Unpaid: '.$customerId.'Subscription ID: '.$subscriptionId); } $team = data_get($subscription, 'team'); if ($team) { $team->subscriptionEnded(); } else { - send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId); + // send_internal_notification('Subscription unpaid but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); } } @@ -273,11 +295,11 @@ class StripeProcessJob implements ShouldQueue if ($team) { $team->subscriptionEnded(); } else { - send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId); + // send_internal_notification('Subscription deleted but no team found in Coolify for customer: '.$customerId); throw new \RuntimeException("No team found in Coolify for customer: {$customerId}"); } } else { - send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId); + // send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId); throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}"); } break; diff --git a/app/Jobs/SubscriptionInvoiceFailedJob.php b/app/Jobs/SubscriptionInvoiceFailedJob.php index dc511f445..927d50467 100755 --- a/app/Jobs/SubscriptionInvoiceFailedJob.php +++ b/app/Jobs/SubscriptionInvoiceFailedJob.php @@ -23,6 +23,47 @@ class SubscriptionInvoiceFailedJob implements ShouldBeEncrypted, ShouldQueue public function handle() { try { + // Double-check subscription status before sending failure notification + $subscription = $this->team->subscription; + if ($subscription && $subscription->stripe_customer_id) { + try { + $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key')); + + if ($subscription->stripe_subscription_id) { + $stripeSubscription = $stripe->subscriptions->retrieve($subscription->stripe_subscription_id); + + if (in_array($stripeSubscription->status, ['active', 'trialing'])) { + if (! $subscription->stripe_invoice_paid) { + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); + } + + return; + } + } + + $invoices = $stripe->invoices->all([ + 'customer' => $subscription->stripe_customer_id, + 'limit' => 3, + ]); + + foreach ($invoices->data as $invoice) { + if ($invoice->paid && $invoice->created > (time() - 3600)) { + $subscription->update([ + 'stripe_invoice_paid' => true, + 'stripe_past_due' => false, + ]); + + return; + } + } + } catch (\Exception $e) { + } + } + + // If we reach here, payment genuinely failed $session = getStripeCustomerPortalSession($this->team); $mail = new MailMessage; $mail->view('emails.subscription-invoice-failed', [ diff --git a/app/Jobs/UpdateStripeCustomerEmailJob.php b/app/Jobs/UpdateStripeCustomerEmailJob.php new file mode 100644 index 000000000..2e86c14a0 --- /dev/null +++ b/app/Jobs/UpdateStripeCustomerEmailJob.php @@ -0,0 +1,133 @@ +onQueue('high'); + } + + public function handle(): void + { + try { + if (! isCloud() || ! $this->team->subscription) { + Log::info('Skipping Stripe email update - not cloud or no subscription', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + ]); + + return; + } + + // Check if the user changing email is a team owner + $isOwner = $this->team->members() + ->wherePivot('role', 'owner') + ->where('users.id', $this->userId) + ->exists(); + + if (! $isOwner) { + Log::info('Skipping Stripe email update - user is not team owner', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + ]); + + return; + } + + // Get current Stripe customer email to verify it matches the user's old email + $stripe_customer_id = data_get($this->team, 'subscription.stripe_customer_id'); + if (! $stripe_customer_id) { + Log::info('Skipping Stripe email update - no Stripe customer ID', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + ]); + + return; + } + + Stripe::setApiKey(config('subscription.stripe_api_key')); + + try { + $stripeCustomer = \Stripe\Customer::retrieve($stripe_customer_id); + $currentStripeEmail = $stripeCustomer->email; + + // Only update if the current Stripe email matches the user's old email + if (strtolower($currentStripeEmail) !== strtolower($this->oldEmail)) { + Log::info('Skipping Stripe email update - Stripe customer email does not match user old email', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'stripe_email' => $currentStripeEmail, + 'user_old_email' => $this->oldEmail, + ]); + + return; + } + + // Update Stripe customer email + \Stripe\Customer::update($stripe_customer_id, ['email' => $this->newEmail]); + + } catch (\Exception $e) { + Log::error('Failed to retrieve or update Stripe customer', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'stripe_customer_id' => $stripe_customer_id, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + + Log::info('Successfully updated Stripe customer email', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'old_email' => $this->oldEmail, + 'new_email' => $this->newEmail, + ]); + } catch (\Exception $e) { + Log::error('Failed to update Stripe customer email', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'old_email' => $this->oldEmail, + 'new_email' => $this->newEmail, + 'error' => $e->getMessage(), + 'attempt' => $this->attempts(), + ]); + + // Re-throw to trigger retry + throw $e; + } + } + + public function failed(\Throwable $exception): void + { + Log::error('Permanently failed to update Stripe customer email after all retries', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'old_email' => $this->oldEmail, + 'new_email' => $this->newEmail, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index edbdd25fe..18dbde0d3 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\PrivateKey; use App\Models\Project; @@ -30,6 +31,12 @@ class Dashboard extends Component public function cleanupQueue() { + try { + $this->authorize('cleanupDeploymentQueue', Application::class); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + return handleError($e, $this); + } + Artisan::queue('cleanup:deployment-queue', [ '--team-id' => currentTeam()->id, ]); diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index eb768d191..819ac3ecd 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -5,6 +5,7 @@ namespace App\Livewire\Destination\New; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; @@ -12,6 +13,8 @@ use Visus\Cuid2\Cuid2; class Docker extends Component { + use AuthorizesRequests; + #[Locked] public $servers; @@ -67,6 +70,7 @@ class Docker extends Component public function submit() { try { + $this->authorize('create', StandaloneDocker::class); $this->validate(); if ($this->isSwarm) { $found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first(); diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php index 5c4d6c170..98cf72376 100644 --- a/app/Livewire/Destination/Show.php +++ b/app/Livewire/Destination/Show.php @@ -5,12 +5,15 @@ namespace App\Livewire\Destination; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + #[Locked] public $destination; @@ -63,6 +66,8 @@ class Show extends Component public function submit() { try { + $this->authorize('update', $this->destination); + $this->syncData(true); $this->dispatch('success', 'Destination saved.'); } catch (\Throwable $e) { @@ -73,6 +78,8 @@ class Show extends Component public function delete() { try { + $this->authorize('delete', $this->destination); + if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) { if ($this->destination->attachedTo()) { return $this->dispatch('error', 'You must delete all resources before deleting this destination.'); diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php new file mode 100644 index 000000000..dacc0d4db --- /dev/null +++ b/app/Livewire/GlobalSearch.php @@ -0,0 +1,372 @@ +searchQuery = ''; + $this->isModalOpen = false; + $this->searchResults = []; + $this->allSearchableItems = []; + } + + public function openSearchModal() + { + $this->isModalOpen = true; + $this->loadSearchableItems(); + $this->dispatch('search-modal-opened'); + } + + public function closeSearchModal() + { + $this->isModalOpen = false; + $this->searchQuery = ''; + $this->searchResults = []; + } + + public static function getCacheKey($teamId) + { + return 'global_search_items_'.$teamId; + } + + public static function clearTeamCache($teamId) + { + Cache::forget(self::getCacheKey($teamId)); + } + + public function updatedSearchQuery() + { + $this->search(); + } + + private function loadSearchableItems() + { + // Try to get from Redis cache first + $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); + + $this->allSearchableItems = Cache::remember($cacheKey, 300, function () { + ray()->showQueries(); + $items = collect(); + $team = auth()->user()->currentTeam(); + + // Get all applications + $applications = Application::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($app) { + // Collect all FQDNs from the application + $fqdns = collect([]); + + // For regular applications + if ($app->fqdn) { + $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + } + + // For docker compose based applications + if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) { + try { + $composeDomains = json_decode($app->docker_compose_domains, true); + if (is_array($composeDomains)) { + foreach ($composeDomains as $serviceName => $domains) { + if (is_array($domains)) { + $fqdns = $fqdns->merge($domains); + } + } + } + } catch (\Exception $e) { + // Ignore JSON parsing errors + } + } + + $fqdnsString = $fqdns->implode(' '); + + return [ + 'id' => $app->id, + 'name' => $app->name, + 'type' => 'application', + 'uuid' => $app->uuid, + 'description' => $app->description, + 'link' => $app->link(), + 'project' => $app->environment->project->name ?? null, + 'environment' => $app->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString), + ]; + }); + + // Get all services + $services = Service::ownedByCurrentTeam() + ->with(['environment.project', 'applications']) + ->get() + ->map(function ($service) { + // Collect all FQDNs from service applications + $fqdns = collect([]); + foreach ($service->applications as $app) { + if ($app->fqdn) { + $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + $fqdns = $fqdns->merge($appFqdns); + } + } + $fqdnsString = $fqdns->implode(' '); + + return [ + 'id' => $service->id, + 'name' => $service->name, + 'type' => 'service', + 'uuid' => $service->uuid, + 'description' => $service->description, + 'link' => $service->link(), + 'project' => $service->environment->project->name ?? null, + 'environment' => $service->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString), + ]; + }); + + // Get all standalone databases + $databases = collect(); + + // PostgreSQL + $databases = $databases->merge( + StandalonePostgresql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'postgresql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' postgresql '.$db->description), + ]; + }) + ); + + // MySQL + $databases = $databases->merge( + StandaloneMysql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mysql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mysql '.$db->description), + ]; + }) + ); + + // MariaDB + $databases = $databases->merge( + StandaloneMariadb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mariadb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mariadb '.$db->description), + ]; + }) + ); + + // MongoDB + $databases = $databases->merge( + StandaloneMongodb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mongodb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mongodb '.$db->description), + ]; + }) + ); + + // Redis + $databases = $databases->merge( + StandaloneRedis::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'redis', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' redis '.$db->description), + ]; + }) + ); + + // KeyDB + $databases = $databases->merge( + StandaloneKeydb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'keydb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' keydb '.$db->description), + ]; + }) + ); + + // Dragonfly + $databases = $databases->merge( + StandaloneDragonfly::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'dragonfly', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' dragonfly '.$db->description), + ]; + }) + ); + + // Clickhouse + $databases = $databases->merge( + StandaloneClickhouse::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'clickhouse', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' clickhouse '.$db->description), + ]; + }) + ); + + // Get all servers + $servers = Server::ownedByCurrentTeam() + ->get() + ->map(function ($server) { + return [ + 'id' => $server->id, + 'name' => $server->name, + 'type' => 'server', + 'uuid' => $server->uuid, + 'description' => $server->description, + 'link' => $server->url(), + 'project' => null, + 'environment' => null, + 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description), + ]; + }); + + // Merge all collections + $items = $items->merge($applications) + ->merge($services) + ->merge($databases) + ->merge($servers); + + return $items->toArray(); + }); + } + + private function search() + { + if (strlen($this->searchQuery) < 2) { + $this->searchResults = []; + + return; + } + + $query = strtolower($this->searchQuery); + + // Case-insensitive search in the items + $this->searchResults = collect($this->allSearchableItems) + ->filter(function ($item) use ($query) { + return str_contains($item['search_text'], $query); + }) + ->take(20) + ->values() + ->toArray(); + } + + public function render() + { + return view('livewire.global-search'); + } +} diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php index 913710588..490515875 100644 --- a/app/Livewire/Help.php +++ b/app/Livewire/Help.php @@ -42,7 +42,7 @@ class Help extends Component 'content' => 'User: `'.auth()->user()?->email.'` with subject: `'.$this->subject.'` has the following problem: `'.$this->description.'`', ]); } else { - send_user_an_email($mail, auth()->user()?->email, 'hi@coollabs.io'); + send_user_an_email($mail, auth()->user()?->email, 'feedback@coollabs.io'); } $this->dispatch('success', 'Feedback sent.', 'We will get in touch with you as soon as possible.'); $this->reset('description', 'subject'); diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index e0425fa17..28d1cb866 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -5,11 +5,14 @@ namespace App\Livewire\Notifications; use App\Models\DiscordNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class Discord extends Component { + use AuthorizesRequests; + public Team $team; public DiscordNotificationSettings $settings; @@ -67,6 +70,7 @@ class Discord extends Component try { $this->team = auth()->user()->currentTeam(); $this->settings = $this->team->discordNotificationSettings; + $this->authorize('view', $this->settings); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); @@ -77,6 +81,7 @@ class Discord extends Component { if ($toModel) { $this->validate(); + $this->authorize('update', $this->settings); $this->settings->discord_enabled = $this->discordEnabled; $this->settings->discord_webhook_url = $this->discordWebhookUrl; @@ -182,6 +187,7 @@ class Discord extends Component public function sendTestNotification() { try { + $this->authorize('sendTest', $this->settings); $this->team->notify(new Test(channel: 'discord')); $this->dispatch('success', 'Test notification sent.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 128321ed2..d62a08417 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -5,6 +5,7 @@ namespace App\Livewire\Notifications; use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -12,6 +13,8 @@ use Livewire\Component; class Email extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh' => '$refresh']; #[Locked] @@ -110,6 +113,7 @@ class Email extends Component $this->team = auth()->user()->currentTeam(); $this->emails = auth()->user()->email; $this->settings = $this->team->emailNotificationSettings; + $this->authorize('view', $this->settings); $this->syncData(); $this->testEmailAddress = auth()->user()->email; } catch (\Throwable $e) { @@ -121,6 +125,7 @@ class Email extends Component { if ($toModel) { $this->validate(); + $this->authorize('update', $this->settings); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_name = $this->smtpFromName; @@ -311,6 +316,7 @@ class Email extends Component public function sendTestEmail() { try { + $this->authorize('sendTest', $this->settings); $this->validate([ 'testEmailAddress' => 'required|email', ], [ @@ -338,6 +344,7 @@ class Email extends Component public function copyFromInstanceSettings() { + $this->authorize('update', $this->settings); $settings = instanceSettings(); $this->smtpFromAddress = $settings->smtp_from_address; $this->smtpFromName = $settings->smtp_from_name; diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index bd5ab79c8..9c7ff64ad 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -5,12 +5,15 @@ namespace App\Livewire\Notifications; use App\Models\PushoverNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Pushover extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh' => '$refresh']; #[Locked] @@ -72,6 +75,7 @@ class Pushover extends Component try { $this->team = auth()->user()->currentTeam(); $this->settings = $this->team->pushoverNotificationSettings; + $this->authorize('view', $this->settings); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); @@ -82,6 +86,7 @@ class Pushover extends Component { if ($toModel) { $this->validate(); + $this->authorize('update', $this->settings); $this->settings->pushover_enabled = $this->pushoverEnabled; $this->settings->pushover_user_key = $this->pushoverUserKey; $this->settings->pushover_api_token = $this->pushoverApiToken; @@ -175,6 +180,7 @@ class Pushover extends Component public function sendTestNotification() { try { + $this->authorize('sendTest', $this->settings); $this->team->notify(new Test(channel: 'pushover')); $this->dispatch('success', 'Test notification sent.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index 9c847ce57..d21399c42 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -5,12 +5,15 @@ namespace App\Livewire\Notifications; use App\Models\SlackNotificationSettings; use App\Models\Team; use App\Notifications\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Slack extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh' => '$refresh']; #[Locked] @@ -69,6 +72,7 @@ class Slack extends Component try { $this->team = auth()->user()->currentTeam(); $this->settings = $this->team->slackNotificationSettings; + $this->authorize('view', $this->settings); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); @@ -79,6 +83,7 @@ class Slack extends Component { if ($toModel) { $this->validate(); + $this->authorize('update', $this->settings); $this->settings->slack_enabled = $this->slackEnabled; $this->settings->slack_webhook_url = $this->slackWebhookUrl; @@ -168,6 +173,7 @@ class Slack extends Component public function sendTestNotification() { try { + $this->authorize('sendTest', $this->settings); $this->team->notify(new Test(channel: 'slack')); $this->dispatch('success', 'Test notification sent.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index 07393d4ea..ca9df47c1 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -5,12 +5,15 @@ namespace App\Livewire\Notifications; use App\Models\Team; use App\Models\TelegramNotificationSettings; use App\Notifications\Test; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Telegram extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh' => '$refresh']; #[Locked] @@ -111,6 +114,7 @@ class Telegram extends Component try { $this->team = auth()->user()->currentTeam(); $this->settings = $this->team->telegramNotificationSettings; + $this->authorize('view', $this->settings); $this->syncData(); } catch (\Throwable $e) { return handleError($e, $this); @@ -121,6 +125,7 @@ class Telegram extends Component { if ($toModel) { $this->validate(); + $this->authorize('update', $this->settings); $this->settings->telegram_enabled = $this->telegramEnabled; $this->settings->telegram_token = $this->telegramToken; $this->settings->telegram_chat_id = $this->telegramChatId; @@ -241,6 +246,7 @@ class Telegram extends Component public function sendTestNotification() { try { + $this->authorize('sendTest', $this->settings); $this->team->notify(new Test(channel: 'telegram')); $this->dispatch('success', 'Test notification sent.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 788802353..4a419a12f 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -4,6 +4,7 @@ namespace App\Livewire\Profile; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Validate; use Livewire\Component; @@ -23,11 +24,25 @@ class Index extends Component #[Validate('required')] public string $name; + public string $new_email = ''; + + public string $email_verification_code = ''; + + public bool $show_email_change = false; + + public bool $show_verification = false; + public function mount() { $this->userId = Auth::id(); $this->name = Auth::user()->name; $this->email = Auth::user()->email; + + // Check if there's a pending email change + if (Auth::user()->hasEmailChangeRequest()) { + $this->new_email = Auth::user()->pending_email; + $this->show_verification = true; + } } public function submit() @@ -46,6 +61,182 @@ class Index extends Component } } + public function requestEmailChange() + { + try { + // For self-hosted, check if email is enabled + if (! isCloud()) { + $settings = instanceSettings(); + if (! $settings->smtp_enabled && ! $settings->resend_enabled) { + $this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.'); + + return; + } + } + + $this->validate([ + 'new_email' => ['required', 'email', 'unique:users,email'], + ]); + + $this->new_email = strtolower($this->new_email); + + // Skip rate limiting in development mode + if (! isDev()) { + // Rate limit by current user's email (1 request per 2 minutes) + $userEmailKey = 'email-change:user:'.Auth::id(); + if (! RateLimiter::attempt($userEmailKey, 1, function () {}, 120)) { + $seconds = RateLimiter::availableIn($userEmailKey); + $this->dispatch('error', 'Too many requests. Please wait '.$seconds.' seconds before trying again.'); + + return; + } + + // Rate limit by new email address (3 requests per hour per email) + $newEmailKey = 'email-change:email:'.md5($this->new_email); + if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) { + $this->dispatch('error', 'This email address has received too many verification requests. Please try again later.'); + + return; + } + + // Additional rate limit by IP address (5 requests per hour) + $ipKey = 'email-change:ip:'.request()->ip(); + if (! RateLimiter::attempt($ipKey, 5, function () {}, 3600)) { + $this->dispatch('error', 'Too many requests from your IP address. Please try again later.'); + + return; + } + } + + Auth::user()->requestEmailChange($this->new_email); + + $this->show_email_change = false; + $this->show_verification = true; + + $this->dispatch('success', 'Verification code sent to '.$this->new_email); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function verifyEmailChange() + { + try { + $this->validate([ + 'email_verification_code' => ['required', 'string', 'size:6'], + ]); + + // Skip rate limiting in development mode + if (! isDev()) { + // Rate limit verification attempts (5 attempts per 10 minutes) + $verifyKey = 'email-verify:user:'.Auth::id(); + if (! RateLimiter::attempt($verifyKey, 5, function () {}, 600)) { + $seconds = RateLimiter::availableIn($verifyKey); + $minutes = ceil($seconds / 60); + $this->dispatch('error', 'Too many verification attempts. Please wait '.$minutes.' minutes before trying again.'); + + // If too many failed attempts, clear the email change request for security + if (RateLimiter::attempts($verifyKey) >= 10) { + Auth::user()->clearEmailChangeRequest(); + $this->new_email = ''; + $this->email_verification_code = ''; + $this->show_verification = false; + $this->dispatch('error', 'Email change request cancelled due to too many failed attempts. Please start over.'); + } + + return; + } + } + + if (! Auth::user()->isEmailChangeCodeValid($this->email_verification_code)) { + $this->dispatch('error', 'Invalid or expired verification code.'); + + return; + } + + if (Auth::user()->confirmEmailChange($this->email_verification_code)) { + // Clear rate limiters on successful verification (only in production) + if (! isDev()) { + $verifyKey = 'email-verify:user:'.Auth::id(); + RateLimiter::clear($verifyKey); + } + + $this->email = Auth::user()->email; + $this->new_email = ''; + $this->email_verification_code = ''; + $this->show_verification = false; + + $this->dispatch('success', 'Email address updated successfully.'); + } else { + $this->dispatch('error', 'Failed to update email address.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function resendVerificationCode() + { + try { + // Check if there's a pending request + if (! Auth::user()->hasEmailChangeRequest()) { + $this->dispatch('error', 'No pending email change request.'); + + return; + } + + // Check if enough time has passed (at least half of the expiry time) + $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); + $halfExpiryMinutes = $expiryMinutes / 2; + $codeExpiry = Auth::user()->email_change_code_expires_at; + $timeSinceCreated = $codeExpiry->subMinutes($expiryMinutes)->diffInMinutes(now()); + + if ($timeSinceCreated < $halfExpiryMinutes) { + $minutesToWait = ceil($halfExpiryMinutes - $timeSinceCreated); + $this->dispatch('error', 'Please wait '.$minutesToWait.' more minutes before requesting a new code.'); + + return; + } + + $pendingEmail = Auth::user()->pending_email; + + // Skip rate limiting in development mode + if (! isDev()) { + // Rate limit by email address + $newEmailKey = 'email-change:email:'.md5(strtolower($pendingEmail)); + if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) { + $this->dispatch('error', 'This email address has received too many verification requests. Please try again later.'); + + return; + } + } + + // Generate and send new code + Auth::user()->requestEmailChange($pendingEmail); + + $this->dispatch('success', 'New verification code sent to '.$pendingEmail); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function cancelEmailChange() + { + Auth::user()->clearEmailChangeRequest(); + $this->new_email = ''; + $this->email_verification_code = ''; + $this->show_email_change = false; + $this->show_verification = false; + + $this->dispatch('success', 'Email change request cancelled.'); + } + + public function showEmailChangeForm() + { + $this->show_email_change = true; + $this->new_email = ''; + } + public function resetPassword() { try { diff --git a/app/Livewire/Project/AddEmpty.php b/app/Livewire/Project/AddEmpty.php index 07873c059..751b4945b 100644 --- a/app/Livewire/Project/AddEmpty.php +++ b/app/Livewire/Project/AddEmpty.php @@ -3,18 +3,29 @@ namespace App\Livewire\Project; use App\Models\Project; -use Livewire\Attributes\Validate; +use App\Support\ValidationPatterns; use Livewire\Component; use Visus\Cuid2\Cuid2; class AddEmpty extends Component { - #[Validate(['required', 'string', 'min:3'])] public string $name; - #[Validate(['nullable', 'string'])] public string $description = ''; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + public function submit() { try { diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index bd1388806..ed15ab258 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -3,11 +3,14 @@ namespace App\Livewire\Project\Application; use App\Models\Application; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class Advanced extends Component { + use AuthorizesRequests; + public Application $application; #[Validate(['boolean'])] @@ -19,9 +22,15 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $isGitLfsEnabled = false; + #[Validate(['boolean'])] + public bool $isGitShallowCloneEnabled = false; + #[Validate(['boolean'])] public bool $isPreviewDeploymentsEnabled = false; + #[Validate(['boolean'])] + public bool $isPrDeploymentsPublicEnabled = false; + #[Validate(['boolean'])] public bool $isAutoDeployEnabled = true; @@ -83,7 +92,9 @@ class Advanced extends Component $this->application->settings->is_force_https_enabled = $this->isForceHttpsEnabled; $this->application->settings->is_git_submodules_enabled = $this->isGitSubmodulesEnabled; $this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled; + $this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled; $this->application->settings->is_preview_deployments_enabled = $this->isPreviewDeploymentsEnabled; + $this->application->settings->is_pr_deployments_public_enabled = $this->isPrDeploymentsPublicEnabled; $this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled; $this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled; $this->application->settings->is_gpu_enabled = $this->isGpuEnabled; @@ -108,7 +119,9 @@ class Advanced extends Component $this->isGitSubmodulesEnabled = $this->application->settings->is_git_submodules_enabled; $this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled; + $this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false; $this->isPreviewDeploymentsEnabled = $this->application->settings->is_preview_deployments_enabled; + $this->isPrDeploymentsPublicEnabled = $this->application->settings->is_pr_deployments_public_enabled ?? false; $this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled; $this->isGpuEnabled = $this->application->settings->is_gpu_enabled; $this->gpuDriver = $this->application->settings->gpu_driver; @@ -137,6 +150,7 @@ class Advanced extends Component public function instantSave() { try { + $this->authorize('update', $this->application); $reset = false; if ($this->isLogDrainEnabled) { if (! $this->application->destination->server->isLogDrainEnabled()) { @@ -175,6 +189,7 @@ class Advanced extends Component public function submit() { try { + $this->authorize('update', $this->application); if ($this->gpuCount && $this->gpuDeviceIds) { $this->dispatch('error', 'You cannot set both GPU count and GPU device IDs.'); $this->gpuCount = null; @@ -192,33 +207,39 @@ class Advanced extends Component public function saveCustomName() { - if (str($this->customInternalName)->isNotEmpty()) { - $this->customInternalName = str($this->customInternalName)->slug()->value(); - } else { - $this->customInternalName = null; - } - if (is_null($this->customInternalName)) { + try { + $this->authorize('update', $this->application); + + if (str($this->customInternalName)->isNotEmpty()) { + $this->customInternalName = str($this->customInternalName)->slug()->value(); + } else { + $this->customInternalName = null; + } + if (is_null($this->customInternalName)) { + $this->syncData(true); + $this->dispatch('success', 'Custom name saved.'); + + return; + } + $customInternalName = $this->customInternalName; + $server = $this->application->destination->server; + $allApplications = $server->applications(); + + $foundSameInternalName = $allApplications->filter(function ($application) { + return $application->id !== $this->application->id && $application->settings->custom_internal_name === $this->customInternalName; + }); + if ($foundSameInternalName->isNotEmpty()) { + $this->dispatch('error', 'This custom container name is already in use by another application on this server.'); + $this->customInternalName = $customInternalName; + $this->syncData(true); + + return; + } $this->syncData(true); $this->dispatch('success', 'Custom name saved.'); - - return; + } catch (\Throwable $e) { + return handleError($e, $this); } - $customInternalName = $this->customInternalName; - $server = $this->application->destination->server; - $allApplications = $server->applications(); - - $foundSameInternalName = $allApplications->filter(function ($application) { - return $application->id !== $this->application->id && $application->settings->custom_internal_name === $this->customInternalName; - }); - if ($foundSameInternalName->isNotEmpty()) { - $this->dispatch('error', 'This custom container name is already in use by another application on this server.'); - $this->customInternalName = $customInternalName; - $this->syncData(true); - - return; - } - $this->syncData(true); - $this->dispatch('success', 'Custom name saved.'); } public function render() diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index 66f387fcf..dccd1e499 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -52,15 +52,24 @@ class DeploymentNavbar extends Component public function cancel() { - $kill_command = "docker rm -f {$this->application_deployment_queue->deployment_uuid}"; + $deployment_uuid = $this->application_deployment_queue->deployment_uuid; + $kill_command = "docker rm -f {$deployment_uuid}"; $build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id; $server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id; + + // First, mark the deployment as cancelled to prevent further processing + $this->application_deployment_queue->update([ + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + try { if ($this->application->settings->is_build_server_enabled) { $server = Server::ownedByCurrentTeam()->find($build_server_id); } else { $server = Server::ownedByCurrentTeam()->find($server_id); } + + // Add cancellation log entry if ($this->application_deployment_queue->logs) { $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); @@ -77,13 +86,35 @@ class DeploymentNavbar extends Component 'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR), ]); } - instant_remote_process([$kill_command], $server); + + // Try to stop the helper container if it exists + // Check if container exists first + $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + // Container exists, kill it + instant_remote_process([$kill_command], $server); + } else { + // Container hasn't started yet + $this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.'); + } + + // Also try to kill any running process if we have a process ID + if ($this->application_deployment_queue->current_process_id) { + try { + $processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}"; + instant_remote_process([$processKillCommand], $server); + } catch (\Throwable $e) { + // Process might already be gone, that's ok + } + } } catch (\Throwable $e) { + // Still mark as cancelled even if cleanup fails return handleError($e, $this); } finally { $this->application_deployment_queue->update([ 'current_process_id' => null, - 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); next_after_cancel($server); } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 58a35caa0..c77d050cb 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -4,7 +4,8 @@ namespace App\Livewire\Project\Application; use App\Actions\Application\GenerateConfig; use App\Models\Application; -use App\Models\EnvironmentVariable; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; use Spatie\Url\Url; @@ -12,6 +13,8 @@ use Visus\Cuid2\Cuid2; class General extends Component { + use AuthorizesRequests; + public string $applicationId; public Application $application; @@ -48,57 +51,101 @@ class General extends Component public $parsedServiceDomains = []; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + protected $listeners = [ 'resetDefaultLabels', 'configurationChanged' => '$refresh', + 'confirmDomainUsage', ]; - protected $rules = [ - 'application.name' => 'required', - 'application.description' => 'nullable', - 'application.fqdn' => 'nullable', - 'application.git_repository' => 'required', - 'application.git_branch' => 'required', - 'application.git_commit_sha' => 'nullable', - 'application.install_command' => 'nullable', - 'application.build_command' => 'nullable', - 'application.start_command' => 'nullable', - 'application.build_pack' => 'required', - 'application.static_image' => 'required', - 'application.base_directory' => 'required', - 'application.publish_directory' => 'nullable', - 'application.ports_exposes' => 'required', - 'application.ports_mappings' => 'nullable', - 'application.custom_network_aliases' => 'nullable', - 'application.dockerfile' => 'nullable', - 'application.docker_registry_image_name' => 'nullable', - 'application.docker_registry_image_tag' => 'nullable', - 'application.dockerfile_location' => 'nullable', - 'application.docker_compose_location' => 'nullable', - 'application.docker_compose' => 'nullable', - 'application.docker_compose_raw' => 'nullable', - 'application.dockerfile_target_build' => 'nullable', - 'application.docker_compose_custom_start_command' => 'nullable', - 'application.docker_compose_custom_build_command' => 'nullable', - 'application.custom_labels' => 'nullable', - 'application.custom_docker_run_options' => 'nullable', - 'application.pre_deployment_command' => 'nullable', - 'application.pre_deployment_command_container' => 'nullable', - 'application.post_deployment_command' => 'nullable', - 'application.post_deployment_command_container' => 'nullable', - 'application.custom_nginx_configuration' => 'nullable', - 'application.settings.is_static' => 'boolean|required', - 'application.settings.is_spa' => 'boolean|required', - 'application.settings.is_build_server_enabled' => 'boolean|required', - 'application.settings.is_container_label_escape_enabled' => 'boolean|required', - 'application.settings.is_container_label_readonly_enabled' => 'boolean|required', - 'application.settings.is_preserve_repository_enabled' => 'boolean|required', - 'application.is_http_basic_auth_enabled' => 'boolean|required', - 'application.http_basic_auth_username' => 'string|nullable', - 'application.http_basic_auth_password' => 'string|nullable', - 'application.watch_paths' => 'nullable', - 'application.redirect' => 'string|required', - ]; + protected function rules(): array + { + return [ + 'application.name' => ValidationPatterns::nameRules(), + 'application.description' => ValidationPatterns::descriptionRules(), + 'application.fqdn' => 'nullable', + 'application.git_repository' => 'required', + 'application.git_branch' => 'required', + 'application.git_commit_sha' => 'nullable', + 'application.install_command' => 'nullable', + 'application.build_command' => 'nullable', + 'application.start_command' => 'nullable', + 'application.build_pack' => 'required', + 'application.static_image' => 'required', + 'application.base_directory' => 'required', + 'application.publish_directory' => 'nullable', + 'application.ports_exposes' => 'required', + 'application.ports_mappings' => 'nullable', + 'application.custom_network_aliases' => 'nullable', + 'application.dockerfile' => 'nullable', + 'application.docker_registry_image_name' => 'nullable', + 'application.docker_registry_image_tag' => 'nullable', + 'application.dockerfile_location' => 'nullable', + 'application.docker_compose_location' => 'nullable', + 'application.docker_compose' => 'nullable', + 'application.docker_compose_raw' => 'nullable', + 'application.dockerfile_target_build' => 'nullable', + 'application.docker_compose_custom_start_command' => 'nullable', + 'application.docker_compose_custom_build_command' => 'nullable', + 'application.custom_labels' => 'nullable', + 'application.custom_docker_run_options' => 'nullable', + 'application.pre_deployment_command' => 'nullable', + 'application.pre_deployment_command_container' => 'nullable', + 'application.post_deployment_command' => 'nullable', + 'application.post_deployment_command_container' => 'nullable', + 'application.custom_nginx_configuration' => 'nullable', + 'application.settings.is_static' => 'boolean|required', + 'application.settings.is_spa' => 'boolean|required', + 'application.settings.is_build_server_enabled' => 'boolean|required', + 'application.settings.is_container_label_escape_enabled' => 'boolean|required', + 'application.settings.is_container_label_readonly_enabled' => 'boolean|required', + 'application.settings.is_preserve_repository_enabled' => 'boolean|required', + 'application.is_http_basic_auth_enabled' => 'boolean|required', + 'application.http_basic_auth_username' => 'string|nullable', + 'application.http_basic_auth_password' => 'string|nullable', + 'application.watch_paths' => 'nullable', + 'application.redirect' => 'string|required', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'application.name.required' => 'The Name field is required.', + 'application.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'application.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'application.git_repository.required' => 'The Git Repository field is required.', + 'application.git_branch.required' => 'The Git Branch field is required.', + 'application.build_pack.required' => 'The Build Pack field is required.', + 'application.static_image.required' => 'The Static Image field is required.', + 'application.base_directory.required' => 'The Base Directory field is required.', + 'application.ports_exposes.required' => 'The Exposed Ports field is required.', + 'application.settings.is_static.required' => 'The Static setting is required.', + 'application.settings.is_static.boolean' => 'The Static setting must be true or false.', + 'application.settings.is_spa.required' => 'The SPA setting is required.', + 'application.settings.is_spa.boolean' => 'The SPA setting must be true or false.', + 'application.settings.is_build_server_enabled.required' => 'The Build Server setting is required.', + 'application.settings.is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.', + 'application.settings.is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.', + 'application.settings.is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.', + 'application.settings.is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.', + 'application.settings.is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.', + 'application.settings.is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.', + 'application.settings.is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.', + 'application.is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.', + 'application.is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.', + 'application.redirect.required' => 'The Redirect setting is required.', + 'application.redirect.string' => 'The Redirect setting must be a string.', + ] + ); + } protected $validationAttributes = [ 'application.name' => 'name', @@ -153,8 +200,14 @@ class General extends Component $this->dispatch('error', $e->getMessage()); } if ($this->application->build_pack === 'dockercompose') { - $this->application->fqdn = null; - $this->application->settings->save(); + // Only update if user has permission + try { + $this->authorize('update', $this->application); + $this->application->fqdn = null; + $this->application->settings->save(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User doesn't have update permission, just continue without saving + } } $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; // Convert service names with dots to use underscores for HTML form binding @@ -170,14 +223,27 @@ class General extends Component $this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled; $this->customLabels = $this->application->parseContainerLabels(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && $this->application->settings->is_container_label_readonly_enabled === true) { - $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); - $this->application->custom_labels = base64_encode($this->customLabels); - $this->application->save(); + // Only update custom labels if user has permission + try { + $this->authorize('update', $this->application); + $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); + $this->application->custom_labels = base64_encode($this->customLabels); + $this->application->save(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User doesn't have update permission, just use existing labels + // $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); + } } $this->initialDockerComposeLocation = $this->application->docker_compose_location; if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) { - $this->initLoadingCompose = true; - $this->dispatch('info', 'Loading docker compose file.'); + // Only load compose file if user has update permission + try { + $this->authorize('update', $this->application); + $this->initLoadingCompose = true; + $this->dispatch('info', 'Loading docker compose file.'); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User doesn't have update permission, skip loading compose file + } } if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) { @@ -187,37 +253,44 @@ class General extends Component public function instantSave() { - if ($this->application->settings->isDirty('is_spa')) { - $this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static'); - } - if ($this->application->isDirty('is_http_basic_auth_enabled')) { - $this->application->save(); - } - $this->application->settings->save(); - $this->dispatch('success', 'Settings saved.'); - $this->application->refresh(); + try { + $this->authorize('update', $this->application); - // If port_exposes changed, reset default labels - if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) { - $this->resetDefaultLabels(false); - } - if ($this->is_preserve_repository_enabled !== $this->application->settings->is_preserve_repository_enabled) { - if ($this->application->settings->is_preserve_repository_enabled === false) { - $this->application->fileStorages->each(function ($storage) { - $storage->is_based_on_git = $this->application->settings->is_preserve_repository_enabled; - $storage->save(); - }); + if ($this->application->settings->isDirty('is_spa')) { + $this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static'); } - } - if ($this->application->settings->is_container_label_readonly_enabled) { - $this->resetDefaultLabels(false); - } + if ($this->application->isDirty('is_http_basic_auth_enabled')) { + $this->application->save(); + } + $this->application->settings->save(); + $this->dispatch('success', 'Settings saved.'); + $this->application->refresh(); + // If port_exposes changed, reset default labels + if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) { + $this->resetDefaultLabels(false); + } + if ($this->is_preserve_repository_enabled !== $this->application->settings->is_preserve_repository_enabled) { + if ($this->application->settings->is_preserve_repository_enabled === false) { + $this->application->fileStorages->each(function ($storage) { + $storage->is_based_on_git = $this->application->settings->is_preserve_repository_enabled; + $storage->save(); + }); + } + } + if ($this->application->settings->is_container_label_readonly_enabled) { + $this->resetDefaultLabels(false); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function loadComposeFile($isInit = false, $showToast = true) { try { + $this->authorize('update', $this->application); + if ($isInit && $this->application->docker_compose_raw) { return; } @@ -228,7 +301,18 @@ class General extends Component return; } - $this->application->parse(); + + // Refresh parsedServiceDomains to reflect any changes in docker_compose_domains + $this->application->refresh(); + $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; + // Convert service names with dots to use underscores for HTML form binding + $sanitizedDomains = []; + foreach ($this->parsedServiceDomains as $serviceName => $domain) { + $sanitizedKey = str($serviceName)->slug('_')->toString(); + $sanitizedDomains[$sanitizedKey] = $domain; + } + $this->parsedServiceDomains = $sanitizedDomains; + $showToast && $this->dispatch('success', 'Docker compose file loaded.'); $this->dispatch('compose_loaded'); $this->dispatch('refreshStorages'); @@ -245,36 +329,41 @@ class General extends Component public function generateDomain(string $serviceName) { - $uuid = new Cuid2; - $domain = generateFqdn($this->application->destination->server, $uuid); - $sanitizedKey = str($serviceName)->slug('_')->toString(); - $this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain; + try { + $this->authorize('update', $this->application); - // Convert back to original service names for storage - $originalDomains = []; - foreach ($this->parsedServiceDomains as $key => $value) { - // Find the original service name by checking parsed services - $originalServiceName = $key; - if (isset($this->parsedServices['services'])) { - foreach ($this->parsedServices['services'] as $originalName => $service) { - if (str($originalName)->slug('_')->toString() === $key) { - $originalServiceName = $originalName; - break; + $uuid = new Cuid2; + $domain = generateUrl(server: $this->application->destination->server, random: $uuid); + $sanitizedKey = str($serviceName)->slug('_')->toString(); + $this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain; + + // Convert back to original service names for storage + $originalDomains = []; + foreach ($this->parsedServiceDomains as $key => $value) { + // Find the original service name by checking parsed services + $originalServiceName = $key; + if (isset($this->parsedServices['services'])) { + foreach ($this->parsedServices['services'] as $originalName => $service) { + if (str($originalName)->slug('_')->toString() === $key) { + $originalServiceName = $originalName; + break; + } } } + $originalDomains[$originalServiceName] = $value; } - $originalDomains[$originalServiceName] = $value; - } - $this->application->docker_compose_domains = json_encode($originalDomains); - $this->application->save(); - $this->dispatch('success', 'Domain generated.'); - if ($this->application->build_pack === 'dockercompose') { - $this->updateServiceEnvironmentVariables(); - $this->loadComposeFile(showToast: false); - } + $this->application->docker_compose_domains = json_encode($originalDomains); + $this->application->save(); + $this->dispatch('success', 'Domain generated.'); + if ($this->application->build_pack === 'dockercompose') { + $this->loadComposeFile(showToast: false); + } - return $domain; + return $domain; + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function updatedApplicationBaseDirectory() @@ -293,6 +382,16 @@ class General extends Component public function updatedApplicationBuildPack() { + // Check if user has permission to update + try { + $this->authorize('update', $this->application); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User doesn't have permission, revert the change and return + $this->application->refresh(); + + return; + } + if ($this->application->build_pack !== 'nixpacks') { $this->application->settings->is_static = false; $this->application->settings->save(); @@ -301,8 +400,26 @@ class General extends Component $this->resetDefaultLabels(false); } if ($this->application->build_pack === 'dockercompose') { - $this->application->fqdn = null; - $this->application->settings->save(); + // Only update if user has permission + try { + $this->authorize('update', $this->application); + $this->application->fqdn = null; + $this->application->settings->save(); + } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + // User doesn't have update permission, just continue without saving + } + } else { + // Clear Docker Compose specific data when switching away from dockercompose + if ($this->application->getOriginal('build_pack') === 'dockercompose') { + $this->application->docker_compose_domains = null; + $this->application->docker_compose_raw = null; + + // Remove SERVICE_FQDN_* and SERVICE_URL_* environment variables + $this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete(); + $this->application->environment_variables()->where('key', 'LIKE', 'SERVICE_URL_%')->delete(); + $this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_FQDN_%')->delete(); + $this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete(); + } } if ($this->application->build_pack === 'static') { $this->application->ports_exposes = $this->ports_exposes = 80; @@ -315,21 +432,33 @@ class General extends Component public function getWildcardDomain() { - $server = data_get($this->application, 'destination.server'); - if ($server) { - $fqdn = generateFqdn($server, $this->application->uuid); - $this->application->fqdn = $fqdn; - $this->application->save(); - $this->resetDefaultLabels(); - $this->dispatch('success', 'Wildcard domain generated.'); + try { + $this->authorize('update', $this->application); + + $server = data_get($this->application, 'destination.server'); + if ($server) { + $fqdn = generateUrl(server: $server, random: $this->application->uuid); + $this->application->fqdn = $fqdn; + $this->application->save(); + $this->resetDefaultLabels(); + $this->dispatch('success', 'Wildcard domain generated.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); } } public function generateNginxConfiguration($type = 'static') { - $this->application->custom_nginx_configuration = defaultNginxConfiguration($type); - $this->application->save(); - $this->dispatch('success', 'Nginx configuration generated.'); + try { + $this->authorize('update', $this->application); + + $this->application->custom_nginx_configuration = defaultNginxConfiguration($type); + $this->application->save(); + $this->dispatch('success', 'Nginx configuration generated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function resetDefaultLabels($manualReset = false) @@ -344,7 +473,7 @@ class General extends Component $this->application->custom_labels = base64_encode($this->customLabels); $this->application->save(); if ($this->application->build_pack === 'dockercompose') { - $this->loadComposeFile(); + $this->loadComposeFile(showToast: false); } $this->dispatch('configurationChanged'); } catch (\Throwable $e) { @@ -358,19 +487,44 @@ class General extends Component $domains = str($this->application->fqdn)->trim()->explode(','); if ($this->application->additional_servers->count() === 0) { foreach ($domains as $domain) { - if (! validate_dns_entry($domain, $this->application->destination->server)) { + if (! validateDNSEntry($domain, $this->application->destination->server)) { $showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

$domain->{$this->application->destination->server->ip}

Check this documentation for further help."); } } } - check_domain_usage(resource: $this->application); + + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return false; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + $this->application->fqdn = $domains->implode(','); $this->resetDefaultLabels(false); } + + return true; + } + + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); } public function setRedirect() { + $this->authorize('update', $this->application); + try { $has_www = collect($this->application->fqdns)->filter(fn ($fqdn) => str($fqdn)->contains('www.'))->count(); if ($has_www === 0 && $this->application->redirect === 'www') { @@ -389,6 +543,7 @@ class General extends Component public function submit($showToaster = true) { try { + $this->authorize('update', $this->application); $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { @@ -411,7 +566,9 @@ class General extends Component $this->application->parseHealthcheckFromDockerfile($this->application->dockerfile); } - $this->checkFqdns(); + if (! $this->checkFqdns()) { + return; // Stop if there are conflicts and user hasn't confirmed + } $this->application->save(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) { @@ -421,7 +578,7 @@ class General extends Component } if ($this->application->build_pack === 'dockercompose' && $this->initialDockerComposeLocation !== $this->application->docker_compose_location) { - $compose_return = $this->loadComposeFile(); + $compose_return = $this->loadComposeFile(showToast: false); if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { return; } @@ -453,45 +610,36 @@ class General extends Component $this->application->publish_directory = rtrim($this->application->publish_directory, '/'); } if ($this->application->build_pack === 'dockercompose') { - // Convert sanitized service names back to original names for storage - $originalDomains = []; - foreach ($this->parsedServiceDomains as $key => $value) { - // Find the original service name by checking parsed services - $originalServiceName = $key; - if (isset($this->parsedServices['services'])) { - foreach ($this->parsedServices['services'] as $originalName => $service) { - if (str($originalName)->slug('_')->toString() === $key) { - $originalServiceName = $originalName; - break; + $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); + if ($this->application->isDirty('docker_compose_domains')) { + foreach ($this->parsedServiceDomains as $service) { + $domain = data_get($service, 'domain'); + if ($domain) { + if (! validateDNSEntry($domain, $this->application->destination->server)) { + $showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

$domain->{$this->application->destination->server->ip}

Check this documentation for further help."); } } } - $originalDomains[$originalServiceName] = $value; - } + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; - $this->application->docker_compose_domains = json_encode($originalDomains); - - foreach ($originalDomains as $serviceName => $service) { - $domain = data_get($service, 'domain'); - if ($domain) { - if (! validate_dns_entry($domain, $this->application->destination->server)) { - $showToaster && $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

$domain->{$this->application->destination->server->ip}

Check this documentation for further help."); + return; } - check_domain_usage(resource: $this->application); + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; } - } - if ($this->application->isDirty('docker_compose_domains')) { + + $this->application->save(); $this->resetDefaultLabels(); } } $this->application->custom_labels = base64_encode($this->customLabels); $this->application->save(); - - // Update SERVICE_FQDN_ and SERVICE_URL_ environment variables for Docker Compose applications - if ($this->application->build_pack === 'dockercompose') { - $this->updateServiceEnvironmentVariables(); - } - $showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!'); } catch (\Throwable $e) { $originalFqdn = $this->application->getOriginal('fqdn'); @@ -523,78 +671,64 @@ class General extends Component $domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]); foreach ($domains as $serviceName => $service) { - $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_'); + $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_'); $domain = data_get($service, 'domain'); + // Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed + $this->application->environment_variables()->where('resourceable_type', Application::class) + ->where('resourceable_id', $this->application->id) + ->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%") + ->delete(); + + $this->application->environment_variables()->where('resourceable_type', Application::class) + ->where('resourceable_id', $this->application->id) + ->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%") + ->delete(); if ($domain) { // Create or update SERVICE_FQDN_ and SERVICE_URL_ variables $fqdn = Url::fromString($domain); $port = $fqdn->getPort(); $path = $fqdn->getPath(); - $fqdnValue = $fqdn->getScheme().'://'.$fqdn->getHost(); - if ($path !== '/') { - $fqdnValue = $fqdnValue.$path; - } - $urlValue = str($domain)->after('://'); + $urlValue = $fqdn->getScheme().'://'.$fqdn->getHost(); if ($path !== '/') { $urlValue = $urlValue.$path; } + $fqdnValue = str($domain)->after('://'); + if ($path !== '/') { + $fqdnValue = $fqdnValue.$path; + } // Create/update SERVICE_FQDN_ - EnvironmentVariable::updateOrCreate([ - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, + $this->application->environment_variables()->updateOrCreate([ 'key' => "SERVICE_FQDN_{$serviceNameFormatted}", ], [ 'value' => $fqdnValue, - 'is_build_time' => false, 'is_preview' => false, ]); // Create/update SERVICE_URL_ - EnvironmentVariable::updateOrCreate([ - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, + $this->application->environment_variables()->updateOrCreate([ 'key' => "SERVICE_URL_{$serviceNameFormatted}", ], [ 'value' => $urlValue, - 'is_build_time' => false, 'is_preview' => false, ]); - // Create/update port-specific variables if port exists - if ($port) { - EnvironmentVariable::updateOrCreate([ - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, + if (filled($port)) { + $this->application->environment_variables()->updateOrCreate([ 'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}", ], [ 'value' => $fqdnValue, - 'is_build_time' => false, 'is_preview' => false, ]); - EnvironmentVariable::updateOrCreate([ - 'resourceable_type' => Application::class, - 'resourceable_id' => $this->application->id, + $this->application->environment_variables()->updateOrCreate([ 'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}", ], [ 'value' => $urlValue, - 'is_build_time' => false, 'is_preview' => false, ]); } - } else { - // Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed - EnvironmentVariable::where('resourceable_type', Application::class) - ->where('resourceable_id', $this->application->id) - ->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%") - ->delete(); - - EnvironmentVariable::where('resourceable_type', Application::class) - ->where('resourceable_id', $this->application->id) - ->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%") - ->delete(); } } } diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 9fd4da68a..62c93611e 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -5,11 +5,14 @@ namespace App\Livewire\Project\Application; use App\Actions\Application\StopApplication; use App\Actions\Docker\GetContainersStatus; use App\Models\Application; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Visus\Cuid2\Cuid2; class Heading extends Component { + use AuthorizesRequests; + public Application $application; public ?string $lastDeploymentInfo = null; @@ -57,11 +60,15 @@ class Heading extends Component public function force_deploy_without_cache() { + $this->authorize('deploy', $this->application); + $this->deploy(force_rebuild: true); } public function deploy(bool $force_rebuild = false) { + $this->authorize('deploy', $this->application); + if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) { $this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.'); @@ -110,12 +117,16 @@ class Heading extends Component public function stop() { + $this->authorize('deploy', $this->application); + $this->dispatch('info', 'Gracefully stopping application.
It could take a while depending on the application.'); StopApplication::dispatch($this->application, false, $this->docker_cleanup); } public function restart() { + $this->authorize('deploy', $this->application); + if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { $this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); diff --git a/app/Livewire/Project/Application/Preview/Form.php b/app/Livewire/Project/Application/Preview/Form.php index edcab44c8..ff951ec54 100644 --- a/app/Livewire/Project/Application/Preview/Form.php +++ b/app/Livewire/Project/Application/Preview/Form.php @@ -3,12 +3,15 @@ namespace App\Livewire\Project\Application\Preview; use App\Models\Application; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; use Spatie\Url\Url; class Form extends Component { + use AuthorizesRequests; + public Application $application; #[Validate('required')] @@ -27,6 +30,7 @@ class Form extends Component public function submit() { try { + $this->authorize('update', $this->application); $this->resetErrorBag(); $this->validate(); $this->application->preview_url_template = str_replace(' ', '', $this->previewUrlTemplate); @@ -41,6 +45,7 @@ class Form extends Component public function resetToDefault() { try { + $this->authorize('update', $this->application); $this->application->preview_url_template = '{{pr_id}}.{{domain}}'; $this->previewUrlTemplate = $this->application->preview_url_template; $this->application->save(); diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 62b1f1929..1cb2ef2c5 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -6,12 +6,15 @@ use App\Actions\Docker\GetContainersStatus; use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationPreview; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; use Visus\Cuid2\Cuid2; class Previews extends Component { + use AuthorizesRequests; + public Application $application; public string $deployment_uuid; @@ -22,6 +25,14 @@ class Previews extends Component public int $rate_limit_remaining; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + + public $pendingPreviewId = null; + protected $rules = [ 'application.previews.*.fqdn' => 'string|nullable', ]; @@ -35,6 +46,7 @@ class Previews extends Component public function load_prs() { try { + $this->authorize('update', $this->application); ['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/pulls"); $this->rate_limit_remaining = $rate_limit_remaining; $this->pull_requests = $data->sortBy('number')->values(); @@ -45,20 +57,44 @@ class Previews extends Component } } + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + if ($this->pendingPreviewId) { + $this->save_preview($this->pendingPreviewId); + $this->pendingPreviewId = null; + } + } + public function save_preview($preview_id) { try { + $this->authorize('update', $this->application); $success = true; $preview = $this->application->previews->find($preview_id); if (data_get_str($preview, 'fqdn')->isNotEmpty()) { $preview->fqdn = str($preview->fqdn)->replaceEnd(',', '')->trim(); $preview->fqdn = str($preview->fqdn)->replaceStart(',', '')->trim(); $preview->fqdn = str($preview->fqdn)->trim()->lower(); - if (! validate_dns_entry($preview->fqdn, $this->application->destination->server)) { + if (! validateDNSEntry($preview->fqdn, $this->application->destination->server)) { $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

$preview->fqdn->{$this->application->destination->server->ip}

Check this documentation for further help."); $success = false; } - check_domain_usage(resource: $this->application, domain: $preview->fqdn); + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application, domain: $preview->fqdn); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + $this->pendingPreviewId = $preview_id; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } } if (! $preview) { @@ -73,29 +109,36 @@ class Previews extends Component public function generate_preview($preview_id) { - $preview = $this->application->previews->find($preview_id); - if (! $preview) { - $this->dispatch('error', 'Preview not found.'); + try { + $this->authorize('update', $this->application); - return; - } - if ($this->application->build_pack === 'dockercompose') { - $preview->generate_preview_fqdn_compose(); + $preview = $this->application->previews->find($preview_id); + if (! $preview) { + $this->dispatch('error', 'Preview not found.'); + + return; + } + if ($this->application->build_pack === 'dockercompose') { + $preview->generate_preview_fqdn_compose(); + $this->application->refresh(); + $this->dispatch('success', 'Domain generated.'); + + return; + } + + $preview->generate_preview_fqdn(); $this->application->refresh(); + $this->dispatch('update_links'); $this->dispatch('success', 'Domain generated.'); - - return; + } catch (\Throwable $e) { + return handleError($e, $this); } - - $preview->generate_preview_fqdn(); - $this->application->refresh(); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); } public function add(int $pull_request_id, ?string $pull_request_html_url = null) { try { + $this->authorize('update', $this->application); if ($this->application->build_pack === 'dockercompose') { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); @@ -131,17 +174,23 @@ class Previews extends Component public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null) { + $this->authorize('deploy', $this->application); + $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true); } public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null) { + $this->authorize('deploy', $this->application); + $this->add($pull_request_id, $pull_request_html_url); $this->deploy($pull_request_id, $pull_request_html_url); } public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false) { + $this->authorize('deploy', $this->application); + try { $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); @@ -182,8 +231,22 @@ class Previews extends Component $this->parameters['deployment_uuid'] = $this->deployment_uuid; } + private function stopContainers(array $containers, $server) + { + $containersToStop = collect($containers)->pluck('Names')->toArray(); + + foreach ($containersToStop as $containerName) { + instant_remote_process(command: [ + "docker stop --time=30 $containerName", + "docker rm -f $containerName", + ], server: $server, throwError: false); + } + } + public function stop(int $pull_request_id) { + $this->authorize('deploy', $this->application); + try { $server = $this->application->destination->server; @@ -206,6 +269,7 @@ class Previews extends Component public function delete(int $pull_request_id) { try { + $this->authorize('delete', $this->application); $preview = ApplicationPreview::where('application_id', $this->application->id) ->where('pull_request_id', $pull_request_id) ->first(); diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index 334d96cad..2632509ea 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -3,12 +3,15 @@ namespace App\Livewire\Project\Application; use App\Models\ApplicationPreview; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; class PreviewsCompose extends Component { + use AuthorizesRequests; + public $service; public $serviceName; @@ -22,59 +25,71 @@ class PreviewsCompose extends Component public function save() { - $domain = data_get($this->service, 'domain'); - $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true); - $docker_compose_domains[$this->serviceName]['domain'] = $domain; - $this->preview->docker_compose_domains = json_encode($docker_compose_domains); - $this->preview->save(); - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain saved.'); + try { + $this->authorize('update', $this->preview->application); + + $domain = data_get($this->service, 'domain'); + $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); + $docker_compose_domains = json_decode($docker_compose_domains, true); + $docker_compose_domains[$this->serviceName]['domain'] = $domain; + $this->preview->docker_compose_domains = json_encode($docker_compose_domains); + $this->preview->save(); + $this->dispatch('update_links'); + $this->dispatch('success', 'Domain saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function generate() { - $domains = collect(json_decode($this->preview->application->docker_compose_domains)) ?? collect(); - $domain = $domains->first(function ($_, $key) { - return $key === $this->serviceName; - }); + try { + $this->authorize('update', $this->preview->application); - $domain_string = data_get($domain, 'domain'); + $domains = collect(json_decode($this->preview->application->docker_compose_domains)) ?? collect(); + $domain = $domains->first(function ($_, $key) { + return $key === $this->serviceName; + }); - // If no domain is set in the main application, generate a default domain - if (empty($domain_string)) { - $server = $this->preview->application->destination->server; - $template = $this->preview->application->preview_url_template; - $random = new Cuid2; + $domain_string = data_get($domain, 'domain'); - // Generate a unique domain like main app services do - $generated_fqdn = generateFqdn($server, $random); + // If no domain is set in the main application, generate a default domain + if (empty($domain_string)) { + $server = $this->preview->application->destination->server; + $template = $this->preview->application->preview_url_template; + $random = new Cuid2; - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; - } else { - // Use the existing domain from the main application - $url = Url::fromString($domain_string); - $template = $this->preview->application->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new Cuid2; - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; + // Generate a unique domain like main app services do + $generated_fqdn = generateUrl(server: $server, random: $random); + + $preview_fqdn = str_replace('{{random}}', $random, $template); + $preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn); + $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); + $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; + } else { + // Use the existing domain from the main application + $url = Url::fromString($domain_string); + $template = $this->preview->application->preview_url_template; + $host = $url->getHost(); + $schema = $url->getScheme(); + $random = new Cuid2; + $preview_fqdn = str_replace('{{random}}', $random, $template); + $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); + $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); + $preview_fqdn = "$schema://$preview_fqdn"; + } + + // Save the generated domain + $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); + $docker_compose_domains = json_decode($docker_compose_domains, true); + $docker_compose_domains[$this->serviceName]['domain'] = $this->service->domain = $preview_fqdn; + $this->preview->docker_compose_domains = json_encode($docker_compose_domains); + $this->preview->save(); + + $this->dispatch('update_links'); + $this->dispatch('success', 'Domain generated.'); + } catch (\Throwable $e) { + return handleError($e, $this); } - - // Save the generated domain - $docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); - $docker_compose_domains = json_decode($docker_compose_domains, true); - $docker_compose_domains[$this->serviceName]['domain'] = $this->service->domain = $preview_fqdn; - $this->preview->docker_compose_domains = json_encode($docker_compose_domains); - $this->preview->save(); - - $this->dispatch('update_links'); - $this->dispatch('success', 'Domain generated.'); } } diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index ff5db1e08..da67a5707 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -3,11 +3,14 @@ namespace App\Livewire\Project\Application; use App\Models\Application; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Visus\Cuid2\Cuid2; class Rollback extends Component { + use AuthorizesRequests; + public Application $application; public $images = []; @@ -23,6 +26,8 @@ class Rollback extends Component public function rollbackImage($commit) { + $this->authorize('deploy', $this->application); + $deployment_uuid = new Cuid2; queue_application_deployment( @@ -43,6 +48,8 @@ class Rollback extends Component public function loadImages($showToast = false) { + $this->authorize('view', $this->application); + try { $image = $this->application->docker_registry_image_name ?? $this->application->uuid; if ($this->application->destination->server->isFunctional()) { diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 932a302ad..29be68b6c 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -4,12 +4,15 @@ namespace App\Livewire\Project\Application; use App\Models\Application; use App\Models\PrivateKey; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Source extends Component { + use AuthorizesRequests; + public Application $application; #[Locked] @@ -81,6 +84,7 @@ class Source extends Component public function setPrivateKey(int $privateKeyId) { try { + $this->authorize('update', $this->application); $this->privateKeyId = $privateKeyId; $this->syncData(true); $this->getPrivateKeys(); @@ -94,7 +98,9 @@ class Source extends Component public function submit() { + try { + $this->authorize('update', $this->application); if (str($this->gitCommitSha)->isEmpty()) { $this->gitCommitSha = 'HEAD'; } @@ -107,7 +113,9 @@ class Source extends Component public function changeSource($sourceId, $sourceType) { + try { + $this->authorize('update', $this->application); $this->application->update([ 'source_id' => $sourceId, 'source_type' => $sourceType, diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index a7c44577c..3b3e42619 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -2,7 +2,6 @@ namespace App\Livewire\Project; -use App\Actions\Application\StopApplication; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Service\StartService; @@ -11,6 +10,7 @@ use App\Jobs\VolumeCloneJob; use App\Models\Environment; use App\Models\Project; use App\Models\Server; +use App\Support\ValidationPatterns; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -42,11 +42,14 @@ class CloneMe extends Component public bool $cloneVolumeData = false; - protected $messages = [ - 'selectedServer' => 'Please select a server.', - 'selectedDestination' => 'Please select a server & destination.', - 'newName' => 'Please enter a name for the new project or environment.', - ]; + protected function messages(): array + { + return array_merge([ + 'selectedServer' => 'Please select a server.', + 'selectedDestination' => 'Please select a server & destination.', + 'newName.required' => 'Please enter a name for the new project or environment.', + ], ValidationPatterns::nameMessages()); + } public function mount($project_uuid) { @@ -90,7 +93,7 @@ class CloneMe extends Component try { $this->validate([ 'selectedDestination' => 'required', - 'newName' => 'required', + 'newName' => ValidationPatterns::nameRules(), ]); if ($type === 'project') { $foundProject = Project::where('name', $this->newName)->first(); @@ -124,144 +127,10 @@ class CloneMe extends Component $databases = $this->environment->databases(); $services = $this->environment->services; foreach ($applications as $application) { - $applicationSettings = $application->settings; - - $uuid = (string) new Cuid2; - $url = $application->fqdn; - if ($this->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $url = generateFqdn($this->server, $uuid); - } - - $newApplication = $application->replicate([ - 'id', - 'created_at', - 'updated_at', - 'additional_servers_count', - 'additional_networks_count', - ])->fill([ - 'uuid' => $uuid, - 'fqdn' => $url, - 'status' => 'exited', + $selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first(); + clone_application($application, $selectedDestination, [ 'environment_id' => $environment->id, - 'destination_id' => $this->selectedDestination, - ]); - $newApplication->save(); - - if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n"); - $newApplication->custom_labels = base64_encode($customLabels); - $newApplication->save(); - } - - $newApplication->settings()->delete(); - if ($applicationSettings) { - $newApplicationSettings = $applicationSettings->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $newApplication->id, - ]); - $newApplicationSettings->save(); - } - - $tags = $application->tags; - foreach ($tags as $tag) { - $newApplication->tags()->attach($tag->id); - } - - $scheduledTasks = $application->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => (string) new Cuid2, - 'application_id' => $newApplication->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $applicationPreviews = $application->previews()->get(); - foreach ($applicationPreviews as $preview) { - $newPreview = $preview->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $newApplication->id, - 'status' => 'exited', - ]); - $newPreview->save(); - } - - $persistentVolumes = $application->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $application->uuid)) { - $newName = str($volume->name)->replace($application->uuid, $newApplication->uuid); - } else { - $newName = $newApplication->uuid.'-'.$volume->name; - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'name' => $newName, - 'resource_id' => $newApplication->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopApplication::dispatch($application, false, false); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $application->destination->server; - $targetServer = $newApplication->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - queue_application_deployment( - deployment_uuid: (string) new Cuid2, - application: $application, - server: $sourceServer, - destination: $application->destination, - no_questions_asked: true - ); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $application->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $newApplication->id, - ]); - $newStorage->save(); - } - - $environmentVaribles = $application->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $newApplication->id, - ]); - $newEnvironmentVariable->save(); - } + ], $this->cloneVolumeData); } foreach ($databases as $database) { @@ -454,7 +323,7 @@ class CloneMe extends Component if ($this->cloneVolumeData) { try { - StopService::dispatch($application, false, false); + StopService::dispatch($application); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; $sourceServer = $application->service->destination->server; @@ -508,7 +377,7 @@ class CloneMe extends Component if ($this->cloneVolumeData) { try { - StopService::dispatch($database->service, false, false); + StopService::dispatch($database->service); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; $sourceServer = $database->service->destination->server; diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index abc88d736..98d076ac0 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -5,6 +5,7 @@ namespace App\Livewire\Project\Database; use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Locked; @@ -14,6 +15,8 @@ use Spatie\Url\Url; class BackupEdit extends Component { + use AuthorizesRequests; + public ScheduledDatabaseBackup $backup; #[Locked] @@ -64,6 +67,9 @@ class BackupEdit extends Component #[Validate(['required', 'boolean'])] public bool $saveS3 = false; + #[Validate(['required', 'boolean'])] + public bool $disableLocalBackup = false; + #[Validate(['nullable', 'integer'])] public ?int $s3StorageId = 1; @@ -98,6 +104,7 @@ class BackupEdit extends Component $this->backup->database_backup_retention_days_s3 = $this->databaseBackupRetentionDaysS3; $this->backup->database_backup_retention_max_storage_s3 = $this->databaseBackupRetentionMaxStorageS3; $this->backup->save_s3 = $this->saveS3; + $this->backup->disable_local_backup = $this->disableLocalBackup; $this->backup->s3_storage_id = $this->s3StorageId; $this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->dump_all = $this->dumpAll; @@ -115,6 +122,7 @@ class BackupEdit extends Component $this->databaseBackupRetentionDaysS3 = $this->backup->database_backup_retention_days_s3; $this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3; $this->saveS3 = $this->backup->save_s3; + $this->disableLocalBackup = $this->backup->disable_local_backup ?? false; $this->s3StorageId = $this->backup->s3_storage_id; $this->databasesToBackup = $this->backup->databases_to_backup; $this->dumpAll = $this->backup->dump_all; @@ -124,6 +132,8 @@ class BackupEdit extends Component public function delete($password) { + $this->authorize('manageBackups', $this->backup->database); + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { if (! Hash::check($password, Auth::user()->password)) { $this->addError('password', 'The provided password is incorrect.'); @@ -181,6 +191,8 @@ class BackupEdit extends Component public function instantSave() { try { + $this->authorize('manageBackups', $this->backup->database); + $this->syncData(true); $this->dispatch('success', 'Backup updated successfully.'); } catch (\Throwable $e) { @@ -193,6 +205,12 @@ class BackupEdit extends Component if (! is_numeric($this->backup->s3_storage_id)) { $this->backup->s3_storage_id = null; } + + // Validate that disable_local_backup can only be true when S3 backup is enabled + if ($this->backup->disable_local_backup && ! $this->backup->save_s3) { + throw new \Exception('Local backup can only be disabled when S3 backup is enabled.'); + } + $isValid = validate_cron_expression($this->backup->frequency); if (! $isValid) { throw new \Exception('Invalid Cron / Human expression'); @@ -203,6 +221,8 @@ class BackupEdit extends Component public function submit() { try { + $this->authorize('manageBackups', $this->backup->database); + $this->syncData(true); $this->dispatch('success', 'Backup updated successfully.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index 3cd360562..decd59a4c 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -3,14 +3,19 @@ namespace App\Livewire\Project\Database; use App\Jobs\DatabaseBackupJob; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class BackupNow extends Component { + use AuthorizesRequests; + public $backup; public function backupNow() { + $this->authorize('manageBackups', $this->backup->database); + DatabaseBackupJob::dispatch($this->backup); $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 2d39c5151..b80775853 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -6,51 +6,42 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Models\Server; use App\Models\StandaloneClickhouse; +use App\Support\ValidationPatterns; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; -use Livewire\Attributes\Validate; use Livewire\Component; class General extends Component { + use AuthorizesRequests; + public Server $server; public StandaloneClickhouse $database; - #[Validate(['required', 'string'])] public string $name; - #[Validate(['nullable', 'string'])] public ?string $description = null; - #[Validate(['required', 'string'])] public string $clickhouseAdminUser; - #[Validate(['required', 'string'])] public string $clickhouseAdminPassword; - #[Validate(['required', 'string'])] public string $image; - #[Validate(['nullable', 'string'])] public ?string $portsMappings = null; - #[Validate(['nullable', 'boolean'])] public ?bool $isPublic = null; - #[Validate(['nullable', 'integer'])] public ?int $publicPort = null; - #[Validate(['nullable', 'string'])] public ?string $customDockerRunOptions = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrl = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrlPublic = null; - #[Validate(['nullable', 'boolean'])] public bool $isLogDrainEnabled = false; public function getListeners() @@ -72,6 +63,40 @@ class General extends Component } } + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'clickhouseAdminUser' => 'required|string', + 'clickhouseAdminPassword' => 'required|string', + 'image' => 'required|string', + 'portsMappings' => 'nullable|string', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'customDockerRunOptions' => 'nullable|string', + 'dbUrl' => 'nullable|string', + 'dbUrlPublic' => 'nullable|string', + 'isLogDrainEnabled' => 'nullable|boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'clickhouseAdminUser.required' => 'The Admin User field is required.', + 'clickhouseAdminUser.string' => 'The Admin User must be a string.', + 'clickhouseAdminPassword.required' => 'The Admin Password field is required.', + 'clickhouseAdminPassword.string' => 'The Admin Password must be a string.', + 'image.required' => 'The Docker Image field is required.', + 'image.string' => 'The Docker Image must be a string.', + 'publicPort.integer' => 'The Public Port must be an integer.', + ] + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -109,6 +134,8 @@ class General extends Component public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -127,6 +154,8 @@ class General extends Component public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; @@ -164,6 +193,8 @@ class General extends Component public function submit() { try { + $this->authorize('update', $this->database); + if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } diff --git a/app/Livewire/Project/Database/Configuration.php b/app/Livewire/Project/Database/Configuration.php index 6c4d0867e..88ecccf99 100644 --- a/app/Livewire/Project/Database/Configuration.php +++ b/app/Livewire/Project/Database/Configuration.php @@ -26,27 +26,38 @@ class Configuration extends Component public function mount() { - $this->currentRoute = request()->route()->getName(); + try { + $this->currentRoute = request()->route()->getName(); - $project = currentTeam() - ->projects() - ->select('id', 'uuid', 'team_id') - ->where('uuid', request()->route('project_uuid')) - ->firstOrFail(); - $environment = $project->environments() - ->select('id', 'name', 'project_id', 'uuid') - ->where('uuid', request()->route('environment_uuid')) - ->firstOrFail(); - $database = $environment->databases() - ->where('uuid', request()->route('database_uuid')) - ->firstOrFail(); + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', request()->route('project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'name', 'project_id', 'uuid') + ->where('uuid', request()->route('environment_uuid')) + ->firstOrFail(); + $database = $environment->databases() + ->where('uuid', request()->route('database_uuid')) + ->firstOrFail(); - $this->database = $database; - $this->project = $project; - $this->environment = $environment; - if (str($this->database->status)->startsWith('running') && is_null($this->database->config_hash)) { - $this->database->isConfigurationChanged(true); - $this->dispatch('configurationChanged'); + $this->database = $database; + $this->project = $project; + $this->environment = $environment; + if (str($this->database->status)->startsWith('running') && is_null($this->database->config_hash)) { + $this->database->isConfigurationChanged(true); + $this->dispatch('configurationChanged'); + } + } catch (\Throwable $e) { + if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + return redirect()->route('dashboard'); + } + if ($e instanceof \Illuminate\Support\ItemNotFoundException) { + return redirect()->route('dashboard'); + } + + return handleError($e, $this); } } diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php index 01108c290..7f807afe2 100644 --- a/app/Livewire/Project/Database/CreateScheduledBackup.php +++ b/app/Livewire/Project/Database/CreateScheduledBackup.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; @@ -10,6 +11,8 @@ use Livewire\Component; class CreateScheduledBackup extends Component { + use AuthorizesRequests; + #[Validate(['required', 'string'])] public $frequency; @@ -41,6 +44,8 @@ class CreateScheduledBackup extends Component public function submit() { try { + $this->authorize('manageBackups', $this->database); + $this->validate(); $isValid = validate_cron_expression($this->frequency); diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 0fffbef31..fabbc7cb4 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -8,54 +8,45 @@ use App\Helpers\SslHelper; use App\Models\Server; use App\Models\SslCertificate; use App\Models\StandaloneDragonfly; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; -use Livewire\Attributes\Validate; use Livewire\Component; class General extends Component { + use AuthorizesRequests; + public Server $server; public StandaloneDragonfly $database; - #[Validate(['required', 'string'])] public string $name; - #[Validate(['nullable', 'string'])] public ?string $description = null; - #[Validate(['required', 'string'])] public string $dragonflyPassword; - #[Validate(['required', 'string'])] public string $image; - #[Validate(['nullable', 'string'])] public ?string $portsMappings = null; - #[Validate(['nullable', 'boolean'])] public ?bool $isPublic = null; - #[Validate(['nullable', 'integer'])] public ?int $publicPort = null; - #[Validate(['nullable', 'string'])] public ?string $customDockerRunOptions = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrl = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrlPublic = null; - #[Validate(['nullable', 'boolean'])] public bool $isLogDrainEnabled = false; public ?Carbon $certificateValidUntil = null; - #[Validate(['nullable', 'boolean'])] public bool $enable_ssl = false; public function getListeners() @@ -85,6 +76,38 @@ class General extends Component } } + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'dragonflyPassword' => 'required|string', + 'image' => 'required|string', + 'portsMappings' => 'nullable|string', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'customDockerRunOptions' => 'nullable|string', + 'dbUrl' => 'nullable|string', + 'dbUrlPublic' => 'nullable|string', + 'isLogDrainEnabled' => 'nullable|boolean', + 'enable_ssl' => 'nullable|boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'dragonflyPassword.required' => 'The Dragonfly Password field is required.', + 'dragonflyPassword.string' => 'The Dragonfly Password must be a string.', + 'image.required' => 'The Docker Image field is required.', + 'image.string' => 'The Docker Image must be a string.', + 'publicPort.integer' => 'The Public Port must be an integer.', + ] + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -122,6 +145,8 @@ class General extends Component public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -140,6 +165,8 @@ class General extends Component public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; @@ -177,6 +204,8 @@ class General extends Component public function submit() { try { + $this->authorize('update', $this->database); + if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -196,6 +225,8 @@ class General extends Component public function instantSaveSSL() { try { + $this->authorize('update', $this->database); + $this->syncData(true); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { @@ -206,6 +237,8 @@ class General extends Component public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index a9783d911..6a287f8cc 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -7,10 +7,13 @@ use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Docker\GetContainersStatus; use App\Events\ServiceStatusChanged; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Heading extends Component { + use AuthorizesRequests; + public $database; public array $parameters; @@ -33,7 +36,10 @@ class Heading extends Component public function activityFinished() { try { - $this->database->started_at ??= now(); + // Only set started_at if database is actually running + if ($this->database->isRunning()) { + $this->database->started_at ??= now(); + } $this->database->save(); if (is_null($this->database->config_hash) || $this->database->isConfigurationChanged()) { @@ -64,6 +70,8 @@ class Heading extends Component public function stop() { try { + $this->authorize('manage', $this->database); + $this->dispatch('info', 'Gracefully stopping database.'); StopDatabase::dispatch($this->database, false, $this->docker_cleanup); } catch (\Exception $e) { @@ -73,12 +81,16 @@ class Heading extends Component public function restart() { + $this->authorize('manage', $this->database); + $activity = RestartDatabase::run($this->database); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } public function start() { + $this->authorize('manage', $this->database); + $activity = StartDatabase::run($this->database); $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); } diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index eb80ca6f6..3f974f63d 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -3,12 +3,15 @@ namespace App\Livewire\Project\Database; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; use Livewire\Component; class Import extends Component { + use AuthorizesRequests; + public bool $unsupported = false; public $resource; @@ -165,12 +168,15 @@ EOD; public function runImport() { + $this->authorize('update', $this->resource); + if ($this->filename === '') { $this->dispatch('error', 'Please select a file to import.'); return; } try { + $this->importRunning = true; $this->importCommands = []; if (filled($this->customLocation)) { $backupFileName = '/tmp/restore_'.$this->resource->uuid; diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index cfc22aedc..7502d001d 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -8,57 +8,47 @@ use App\Helpers\SslHelper; use App\Models\Server; use App\Models\SslCertificate; use App\Models\StandaloneKeydb; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; -use Livewire\Attributes\Validate; use Livewire\Component; class General extends Component { + use AuthorizesRequests; + public Server $server; public StandaloneKeydb $database; - #[Validate(['required', 'string'])] public string $name; - #[Validate(['nullable', 'string'])] public ?string $description = null; - #[Validate(['nullable', 'string'])] public ?string $keydbConf = null; - #[Validate(['required', 'string'])] public string $keydbPassword; - #[Validate(['required', 'string'])] public string $image; - #[Validate(['nullable', 'string'])] public ?string $portsMappings = null; - #[Validate(['nullable', 'boolean'])] public ?bool $isPublic = null; - #[Validate(['nullable', 'integer'])] public ?int $publicPort = null; - #[Validate(['nullable', 'string'])] public ?string $customDockerRunOptions = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrl = null; - #[Validate(['nullable', 'string'])] public ?string $dbUrlPublic = null; - #[Validate(['nullable', 'boolean'])] public bool $isLogDrainEnabled = false; public ?Carbon $certificateValidUntil = null; - #[Validate(['boolean'])] public bool $enable_ssl = false; public function getListeners() @@ -89,6 +79,41 @@ class General extends Component } } + protected function rules(): array + { + $baseRules = [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'keydbConf' => 'nullable|string', + 'keydbPassword' => 'required|string', + 'image' => 'required|string', + 'portsMappings' => 'nullable|string', + 'isPublic' => 'nullable|boolean', + 'publicPort' => 'nullable|integer', + 'customDockerRunOptions' => 'nullable|string', + 'dbUrl' => 'nullable|string', + 'dbUrlPublic' => 'nullable|string', + 'isLogDrainEnabled' => 'nullable|boolean', + 'enable_ssl' => 'boolean', + ]; + + return $baseRules; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'keydbPassword.required' => 'The KeyDB Password field is required.', + 'keydbPassword.string' => 'The KeyDB Password must be a string.', + 'image.required' => 'The Docker Image field is required.', + 'image.string' => 'The Docker Image must be a string.', + 'publicPort.integer' => 'The Public Port must be an integer.', + ] + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -128,6 +153,8 @@ class General extends Component public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -146,6 +173,8 @@ class General extends Component public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->isPublic && ! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; @@ -183,6 +212,8 @@ class General extends Component public function submit() { try { + $this->authorize('manageEnvironment', $this->database); + if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -202,6 +233,8 @@ class General extends Component public function instantSaveSSL() { try { + $this->authorize('update', $this->database); + $this->syncData(true); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { @@ -212,6 +245,8 @@ class General extends Component public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 174f907c8..c82c4538f 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -8,13 +8,17 @@ use App\Helpers\SslHelper; use App\Models\Server; use App\Models\SslCertificate; use App\Models\StandaloneMariadb; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh']; public Server $server; @@ -37,22 +41,43 @@ class General extends Component ]; } - protected $rules = [ - 'database.name' => 'required', - 'database.description' => 'nullable', - 'database.mariadb_root_password' => 'required', - 'database.mariadb_user' => 'required', - 'database.mariadb_password' => 'required', - 'database.mariadb_database' => 'required', - 'database.mariadb_conf' => 'nullable', - 'database.image' => 'required', - 'database.ports_mappings' => 'nullable', - 'database.is_public' => 'nullable|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_log_drain_enabled' => 'nullable|boolean', - 'database.custom_docker_run_options' => 'nullable', - 'database.enable_ssl' => 'boolean', - ]; + protected function rules(): array + { + return [ + 'database.name' => ValidationPatterns::nameRules(), + 'database.description' => ValidationPatterns::descriptionRules(), + 'database.mariadb_root_password' => 'required', + 'database.mariadb_user' => 'required', + 'database.mariadb_password' => 'required', + 'database.mariadb_database' => 'required', + 'database.mariadb_conf' => 'nullable', + 'database.image' => 'required', + 'database.ports_mappings' => 'nullable', + 'database.is_public' => 'nullable|boolean', + 'database.public_port' => 'nullable|integer', + 'database.is_log_drain_enabled' => 'nullable|boolean', + 'database.custom_docker_run_options' => 'nullable', + 'database.enable_ssl' => 'boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'database.name.required' => 'The Name field is required.', + 'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'database.mariadb_root_password.required' => 'The Root Password field is required.', + 'database.mariadb_user.required' => 'The MariaDB User field is required.', + 'database.mariadb_password.required' => 'The MariaDB Password field is required.', + 'database.mariadb_database.required' => 'The MariaDB Database field is required.', + 'database.image.required' => 'The Docker Image field is required.', + 'database.public_port.integer' => 'The Public Port must be an integer.', + ] + ); + } protected $validationAttributes = [ 'database.name' => 'Name', @@ -86,6 +111,8 @@ class General extends Component public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -103,6 +130,8 @@ class General extends Component public function submit() { try { + $this->authorize('update', $this->database); + if (str($this->database->public_port)->isEmpty()) { $this->database->public_port = null; } @@ -123,6 +152,8 @@ class General extends Component public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; @@ -154,6 +185,8 @@ class General extends Component public function instantSaveSSL() { try { + $this->authorize('update', $this->database); + $this->database->save(); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { @@ -164,6 +197,8 @@ class General extends Component public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 2ac6e43b7..4fbc45437 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -8,13 +8,17 @@ use App\Helpers\SslHelper; use App\Models\Server; use App\Models\SslCertificate; use App\Models\StandaloneMongodb; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh']; public Server $server; @@ -37,22 +41,43 @@ class General extends Component ]; } - protected $rules = [ - 'database.name' => 'required', - 'database.description' => 'nullable', - 'database.mongo_conf' => 'nullable', - 'database.mongo_initdb_root_username' => 'required', - 'database.mongo_initdb_root_password' => 'required', - 'database.mongo_initdb_database' => 'required', - 'database.image' => 'required', - 'database.ports_mappings' => 'nullable', - 'database.is_public' => 'nullable|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_log_drain_enabled' => 'nullable|boolean', - 'database.custom_docker_run_options' => 'nullable', - 'database.enable_ssl' => 'boolean', - 'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-full', - ]; + protected function rules(): array + { + return [ + 'database.name' => ValidationPatterns::nameRules(), + 'database.description' => ValidationPatterns::descriptionRules(), + 'database.mongo_conf' => 'nullable', + 'database.mongo_initdb_root_username' => 'required', + 'database.mongo_initdb_root_password' => 'required', + 'database.mongo_initdb_database' => 'required', + 'database.image' => 'required', + 'database.ports_mappings' => 'nullable', + 'database.is_public' => 'nullable|boolean', + 'database.public_port' => 'nullable|integer', + 'database.is_log_drain_enabled' => 'nullable|boolean', + 'database.custom_docker_run_options' => 'nullable', + 'database.enable_ssl' => 'boolean', + 'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-full', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'database.name.required' => 'The Name field is required.', + 'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'database.mongo_initdb_root_username.required' => 'The Root Username field is required.', + 'database.mongo_initdb_root_password.required' => 'The Root Password field is required.', + 'database.mongo_initdb_database.required' => 'The MongoDB Database field is required.', + 'database.image.required' => 'The Docker Image field is required.', + 'database.public_port.integer' => 'The Public Port must be an integer.', + 'database.ssl_mode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.', + ] + ); + } protected $validationAttributes = [ 'database.name' => 'Name', @@ -86,6 +111,8 @@ class General extends Component public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -103,6 +130,8 @@ class General extends Component public function submit() { try { + $this->authorize('update', $this->database); + if (str($this->database->public_port)->isEmpty()) { $this->database->public_port = null; } @@ -126,6 +155,8 @@ class General extends Component public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; @@ -162,6 +193,8 @@ class General extends Component public function instantSaveSSL() { try { + $this->authorize('update', $this->database); + $this->database->save(); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { @@ -172,6 +205,8 @@ class General extends Component public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index ea0ea4691..ada1b3a2c 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -8,13 +8,17 @@ use App\Helpers\SslHelper; use App\Models\Server; use App\Models\SslCertificate; use App\Models\StandaloneMysql; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component { + use AuthorizesRequests; + protected $listeners = ['refresh']; public StandaloneMysql $database; @@ -37,23 +41,45 @@ class General extends Component ]; } - protected $rules = [ - 'database.name' => 'required', - 'database.description' => 'nullable', - 'database.mysql_root_password' => 'required', - 'database.mysql_user' => 'required', - 'database.mysql_password' => 'required', - 'database.mysql_database' => 'required', - 'database.mysql_conf' => 'nullable', - 'database.image' => 'required', - 'database.ports_mappings' => 'nullable', - 'database.is_public' => 'nullable|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_log_drain_enabled' => 'nullable|boolean', - 'database.custom_docker_run_options' => 'nullable', - 'database.enable_ssl' => 'boolean', - 'database.ssl_mode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY', - ]; + protected function rules(): array + { + return [ + 'database.name' => ValidationPatterns::nameRules(), + 'database.description' => ValidationPatterns::descriptionRules(), + 'database.mysql_root_password' => 'required', + 'database.mysql_user' => 'required', + 'database.mysql_password' => 'required', + 'database.mysql_database' => 'required', + 'database.mysql_conf' => 'nullable', + 'database.image' => 'required', + 'database.ports_mappings' => 'nullable', + 'database.is_public' => 'nullable|boolean', + 'database.public_port' => 'nullable|integer', + 'database.is_log_drain_enabled' => 'nullable|boolean', + 'database.custom_docker_run_options' => 'nullable', + 'database.enable_ssl' => 'boolean', + 'database.ssl_mode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'database.name.required' => 'The Name field is required.', + 'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'database.mysql_root_password.required' => 'The Root Password field is required.', + 'database.mysql_user.required' => 'The MySQL User field is required.', + 'database.mysql_password.required' => 'The MySQL Password field is required.', + 'database.mysql_database.required' => 'The MySQL Database field is required.', + 'database.image.required' => 'The Docker Image field is required.', + 'database.public_port.integer' => 'The Public Port must be an integer.', + 'database.ssl_mode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.', + ] + ); + } protected $validationAttributes = [ 'database.name' => 'Name', @@ -88,6 +114,8 @@ class General extends Component public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -105,6 +133,8 @@ class General extends Component public function submit() { try { + $this->authorize('update', $this->database); + if (str($this->database->public_port)->isEmpty()) { $this->database->public_port = null; } @@ -125,6 +155,8 @@ class General extends Component public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; @@ -161,6 +193,8 @@ class General extends Component public function instantSaveSSL() { try { + $this->authorize('update', $this->database); + $this->database->save(); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { @@ -171,6 +205,8 @@ class General extends Component public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index d512445b7..2d37620b9 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -8,13 +8,17 @@ use App\Helpers\SslHelper; use App\Models\Server; use App\Models\SslCertificate; use App\Models\StandalonePostgresql; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component { + use AuthorizesRequests; + public StandalonePostgresql $database; public Server $server; @@ -41,25 +45,46 @@ class General extends Component ]; } - protected $rules = [ - 'database.name' => 'required', - 'database.description' => 'nullable', - 'database.postgres_user' => 'required', - 'database.postgres_password' => 'required', - 'database.postgres_db' => 'required', - 'database.postgres_initdb_args' => 'nullable', - 'database.postgres_host_auth_method' => 'nullable', - 'database.postgres_conf' => 'nullable', - 'database.init_scripts' => 'nullable', - 'database.image' => 'required', - 'database.ports_mappings' => 'nullable', - 'database.is_public' => 'nullable|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_log_drain_enabled' => 'nullable|boolean', - 'database.custom_docker_run_options' => 'nullable', - 'database.enable_ssl' => 'boolean', - 'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full', - ]; + protected function rules(): array + { + return [ + 'database.name' => ValidationPatterns::nameRules(), + 'database.description' => ValidationPatterns::descriptionRules(), + 'database.postgres_user' => 'required', + 'database.postgres_password' => 'required', + 'database.postgres_db' => 'required', + 'database.postgres_initdb_args' => 'nullable', + 'database.postgres_host_auth_method' => 'nullable', + 'database.postgres_conf' => 'nullable', + 'database.init_scripts' => 'nullable', + 'database.image' => 'required', + 'database.ports_mappings' => 'nullable', + 'database.is_public' => 'nullable|boolean', + 'database.public_port' => 'nullable|integer', + 'database.is_log_drain_enabled' => 'nullable|boolean', + 'database.custom_docker_run_options' => 'nullable', + 'database.enable_ssl' => 'boolean', + 'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'database.name.required' => 'The Name field is required.', + 'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'database.postgres_user.required' => 'The Postgres User field is required.', + 'database.postgres_password.required' => 'The Postgres Password field is required.', + 'database.postgres_db.required' => 'The Postgres Database field is required.', + 'database.image.required' => 'The Docker Image field is required.', + 'database.public_port.integer' => 'The Public Port must be an integer.', + 'database.ssl_mode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.', + ] + ); + } protected $validationAttributes = [ 'database.name' => 'Name', @@ -96,6 +121,8 @@ class General extends Component public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -118,6 +145,8 @@ class General extends Component public function instantSaveSSL() { try { + $this->authorize('update', $this->database); + $this->database->save(); $this->dispatch('success', 'SSL configuration updated.'); $this->db_url = $this->database->internal_db_url; @@ -130,6 +159,8 @@ class General extends Component public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { @@ -162,6 +193,8 @@ class General extends Component public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; @@ -192,6 +225,8 @@ class General extends Component public function save_init_script($script) { + $this->authorize('update', $this->database); + $initScripts = collect($this->database->init_scripts ?? []); $existingScript = $initScripts->firstWhere('filename', $script['filename']); @@ -242,6 +277,8 @@ class General extends Component public function delete_init_script($script) { + $this->authorize('update', $this->database); + $collection = collect($this->database->init_scripts); $found = $collection->firstWhere('filename', $script['filename']); if ($found) { @@ -276,6 +313,8 @@ class General extends Component public function save_new_init_script() { + $this->authorize('update', $this->database); + $this->validate([ 'new_filename' => 'required|string', 'new_content' => 'required|string', @@ -305,6 +344,8 @@ class General extends Component public function submit() { try { + $this->authorize('update', $this->database); + if (str($this->database->public_port)->isEmpty()) { $this->database->public_port = null; } diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index f03f1256d..1eb4f5c8d 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -8,13 +8,17 @@ use App\Helpers\SslHelper; use App\Models\Server; use App\Models\SslCertificate; use App\Models\StandaloneRedis; +use App\Support\ValidationPatterns; use Carbon\Carbon; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component { + use AuthorizesRequests; + public Server $server; public StandaloneRedis $database; @@ -42,20 +46,39 @@ class General extends Component ]; } - protected $rules = [ - 'database.name' => 'required', - 'database.description' => 'nullable', - 'database.redis_conf' => 'nullable', - 'database.image' => 'required', - 'database.ports_mappings' => 'nullable', - 'database.is_public' => 'nullable|boolean', - 'database.public_port' => 'nullable|integer', - 'database.is_log_drain_enabled' => 'nullable|boolean', - 'database.custom_docker_run_options' => 'nullable', - 'redis_username' => 'required', - 'redis_password' => 'required', - 'database.enable_ssl' => 'boolean', - ]; + protected function rules(): array + { + return [ + 'database.name' => ValidationPatterns::nameRules(), + 'database.description' => ValidationPatterns::descriptionRules(), + 'database.redis_conf' => 'nullable', + 'database.image' => 'required', + 'database.ports_mappings' => 'nullable', + 'database.is_public' => 'nullable|boolean', + 'database.public_port' => 'nullable|integer', + 'database.is_log_drain_enabled' => 'nullable|boolean', + 'database.custom_docker_run_options' => 'nullable', + 'redis_username' => 'required', + 'redis_password' => 'required', + 'database.enable_ssl' => 'boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'database.name.required' => 'The Name field is required.', + 'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'database.image.required' => 'The Docker Image field is required.', + 'database.public_port.integer' => 'The Public Port must be an integer.', + 'redis_username.required' => 'The Redis Username field is required.', + 'redis_password.required' => 'The Redis Password field is required.', + ] + ); + } protected $validationAttributes = [ 'database.name' => 'Name', @@ -85,6 +108,8 @@ class General extends Component public function instantSaveAdvanced() { try { + $this->authorize('update', $this->database); + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -102,6 +127,8 @@ class General extends Component public function submit() { try { + $this->authorize('manageEnvironment', $this->database); + $this->validate(); if (version_compare($this->redis_version, '6.0', '>=')) { @@ -127,6 +154,8 @@ class General extends Component public function instantSave() { try { + $this->authorize('update', $this->database); + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; @@ -158,6 +187,8 @@ class General extends Component public function instantSaveSSL() { try { + $this->authorize('update', $this->database); + $this->database->save(); $this->dispatch('success', 'SSL configuration updated.'); } catch (Exception $e) { @@ -168,6 +199,8 @@ class General extends Component public function regenerateSslCertificate() { try { + $this->authorize('update', $this->database); + $existingCert = $this->database->sslCertificates()->first(); if (! $existingCert) { diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php index 51d8cb33e..1cf5e53f6 100644 --- a/app/Livewire/Project/Database/ScheduledBackups.php +++ b/app/Livewire/Project/Database/ScheduledBackups.php @@ -3,10 +3,13 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class ScheduledBackups extends Component { + use AuthorizesRequests; + public $database; public $parameters; @@ -53,6 +56,8 @@ class ScheduledBackups extends Component public function setCustomType() { + $this->authorize('update', $this->database); + $this->database->custom_type = $this->custom_type; $this->database->save(); $this->dispatch('success', 'Database type set.'); @@ -61,7 +66,10 @@ class ScheduledBackups extends Component public function delete($scheduled_backup_id): void { - $this->database->scheduledBackups->find($scheduled_backup_id)->delete(); + $backup = $this->database->scheduledBackups->find($scheduled_backup_id); + $this->authorize('manageBackups', $this->database); + + $backup->delete(); $this->dispatch('success', 'Scheduled backup deleted.'); $this->refreshScheduledBackups(); } diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index 1ee5de269..e97206081 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -3,10 +3,13 @@ namespace App\Livewire\Project; use App\Models\Environment; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class DeleteEnvironment extends Component { + use AuthorizesRequests; + public int $environment_id; public bool $disabled = false; @@ -31,6 +34,8 @@ class DeleteEnvironment extends Component 'environment_id' => 'required|int', ]); $environment = Environment::findOrFail($this->environment_id); + $this->authorize('delete', $environment); + if ($environment->isEmpty()) { $environment->delete(); diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index f320a19b0..26b35b2e7 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -3,10 +3,13 @@ namespace App\Livewire\Project; use App\Models\Project; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class DeleteProject extends Component { + use AuthorizesRequests; + public array $parameters; public int $project_id; @@ -27,6 +30,8 @@ class DeleteProject extends Component 'project_id' => 'required|int', ]); $project = Project::findOrFail($this->project_id); + $this->authorize('delete', $project); + if ($project->isEmpty()) { $project->delete(); diff --git a/app/Livewire/Project/Edit.php b/app/Livewire/Project/Edit.php index 463febb10..a2d73eb5f 100644 --- a/app/Livewire/Project/Edit.php +++ b/app/Livewire/Project/Edit.php @@ -3,19 +3,30 @@ namespace App\Livewire\Project; use App\Models\Project; -use Livewire\Attributes\Validate; +use App\Support\ValidationPatterns; use Livewire\Component; class Edit extends Component { public Project $project; - #[Validate(['required', 'string', 'min:3', 'max:255'])] public string $name; - #[Validate(['nullable', 'string', 'max:255'])] public ?string $description = null; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + public function mount(string $project_uuid) { try { diff --git a/app/Livewire/Project/EnvironmentEdit.php b/app/Livewire/Project/EnvironmentEdit.php index e98b088ec..d57be2cc8 100644 --- a/app/Livewire/Project/EnvironmentEdit.php +++ b/app/Livewire/Project/EnvironmentEdit.php @@ -4,8 +4,8 @@ namespace App\Livewire\Project; use App\Models\Application; use App\Models\Project; +use App\Support\ValidationPatterns; use Livewire\Attributes\Locked; -use Livewire\Attributes\Validate; use Livewire\Component; class EnvironmentEdit extends Component @@ -17,12 +17,23 @@ class EnvironmentEdit extends Component #[Locked] public $environment; - #[Validate(['required', 'string', 'min:3', 'max:255'])] public string $name; - #[Validate(['nullable', 'string', 'max:255'])] public ?string $description = null; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + public function mount(string $project_uuid, string $environment_uuid) { try { diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 5347d74f0..5381fa78d 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -20,6 +20,7 @@ class Index extends Component $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); $this->projects = Project::ownedByCurrentTeam()->get()->map(function ($project) { $project->settingsRoute = route('project.edit', ['project_uuid' => $project->uuid]); + $project->canUpdate = auth()->user()->can('update', $project); return $project; }); diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php index 7c81e810c..5cda1dedd 100644 --- a/app/Livewire/Project/New/DockerCompose.php +++ b/app/Livewire/Project/New/DockerCompose.php @@ -63,7 +63,6 @@ class DockerCompose extends Component EnvironmentVariable::create([ 'key' => $key, 'value' => $variable, - 'is_build_time' => false, 'is_preview' => false, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 7d68ce068..dbb223de2 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -60,7 +60,7 @@ class DockerImage extends Component 'health_check_enabled' => false, ]); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->update([ 'name' => 'docker-image-'.$application->uuid, 'fqdn' => $fqdn, diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index b1b0aef15..0f496e6db 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -7,6 +7,7 @@ use App\Models\GithubApp; use App\Models\Project; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use App\Rules\ValidGitBranch; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; use Livewire\Component; @@ -155,6 +156,21 @@ class GithubPrivateRepository extends Component public function submit() { try { + // Validate git repository parts and branch + $validator = validator([ + 'selected_repository_owner' => $this->selected_repository_owner, + 'selected_repository_repo' => $this->selected_repository_repo, + 'selected_branch_name' => $this->selected_branch_name, + ], [ + 'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/', + 'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/', + 'selected_branch_name' => ['required', 'string', new ValidGitBranch], + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first()); + } + $destination_uuid = $this->query['destination']; $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); if (! $destination) { @@ -171,8 +187,8 @@ class GithubPrivateRepository extends Component $application = Application::create([ 'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name), 'repository_project_id' => $this->selected_repository_id, - 'git_repository' => "{$this->selected_repository_owner}/{$this->selected_repository_repo}", - 'git_branch' => $this->selected_branch_name, + 'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(), + 'git_branch' => str($this->selected_branch_name)->trim()->toString(), 'build_pack' => $this->build_pack, 'ports_exposes' => $this->port, 'publish_directory' => $this->publish_directory, @@ -192,7 +208,7 @@ class GithubPrivateRepository extends Component $application['docker_compose_location'] = $this->docker_compose_location; $application['base_directory'] = $this->base_directory; } - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid); diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 01b0c9ae8..5ff8f9137 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -9,6 +9,8 @@ use App\Models\PrivateKey; use App\Models\Project; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use App\Rules\ValidGitBranch; +use App\Rules\ValidGitRepositoryUrl; use Illuminate\Support\Str; use Livewire\Component; use Spatie\Url\Url; @@ -53,17 +55,29 @@ class GithubPrivateRepositoryDeployKey extends Component private ?string $git_host = null; - private string $git_repository; + private ?string $git_repository = null; protected $rules = [ - 'repository_url' => 'required', - 'branch' => 'required|string', + 'repository_url' => ['required', 'string'], + 'branch' => ['required', 'string'], 'port' => 'required|numeric', 'is_static' => 'required|boolean', 'publish_directory' => 'nullable|string', 'build_pack' => 'required|string', ]; + protected function rules() + { + return [ + 'repository_url' => ['required', 'string', new ValidGitRepositoryUrl], + 'branch' => ['required', 'string', new ValidGitBranch], + 'port' => 'required|numeric', + 'is_static' => 'required|boolean', + 'publish_directory' => 'nullable|string', + 'build_pack' => 'required|string', + ]; + } + protected $validationAttributes = [ 'repository_url' => 'Repository', 'branch' => 'Branch', @@ -135,6 +149,9 @@ class GithubPrivateRepositoryDeployKey extends Component $this->get_git_source(); + // Note: git_repository has already been validated and transformed in get_git_source() + // It may now be in SSH format (git@host:repo.git) which is valid for deploy keys + $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); if ($this->git_source === 'other') { @@ -177,7 +194,7 @@ class GithubPrivateRepositoryDeployKey extends Component $application->settings->is_static = $this->is_static; $application->settings->save(); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->name = generate_random_name($application->uuid); $application->save(); @@ -194,6 +211,15 @@ class GithubPrivateRepositoryDeployKey extends Component private function get_git_source() { + // Validate repository URL before parsing + $validator = validator(['repository_url' => $this->repository_url], [ + 'repository_url' => ['required', 'string', new ValidGitRepositoryUrl], + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); + } + $this->repository_url_parsed = Url::fromString($this->repository_url); $this->git_host = $this->repository_url_parsed->getHost(); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); @@ -206,8 +232,10 @@ class GithubPrivateRepositoryDeployKey extends Component if (str($this->repository_url)->startsWith('http')) { $this->git_host = $this->repository_url_parsed->getHost(); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); + // Convert to SSH format for deploy key usage $this->git_repository = Str::finish("git@$this->git_host:$this->git_repository", '.git'); } else { + // If it's already in SSH format, just use it as-is $this->git_repository = $this->repository_url; } $this->git_source = 'other'; diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 45b3b5726..f5978aea1 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -9,6 +9,8 @@ use App\Models\Project; use App\Models\Service; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use App\Rules\ValidGitBranch; +use App\Rules\ValidGitRepositoryUrl; use Carbon\Carbon; use Livewire\Component; use Spatie\Url\Url; @@ -62,7 +64,7 @@ class PublicGitRepository extends Component public bool $new_compose_services = false; protected $rules = [ - 'repository_url' => 'required|url', + 'repository_url' => ['required', 'string'], 'port' => 'required|numeric', 'isStatic' => 'required|boolean', 'publish_directory' => 'nullable|string', @@ -71,6 +73,20 @@ class PublicGitRepository extends Component 'docker_compose_location' => 'nullable|string', ]; + protected function rules() + { + return [ + 'repository_url' => ['required', 'string', new ValidGitRepositoryUrl], + 'port' => 'required|numeric', + 'isStatic' => 'required|boolean', + 'publish_directory' => 'nullable|string', + 'build_pack' => 'required|string', + 'base_directory' => 'nullable|string', + 'docker_compose_location' => 'nullable|string', + 'git_branch' => ['required', 'string', new ValidGitBranch], + ]; + } + protected $validationAttributes = [ 'repository_url' => 'repository', 'port' => 'port', @@ -141,6 +157,15 @@ class PublicGitRepository extends Component public function loadBranch() { try { + // Validate repository URL + $validator = validator(['repository_url' => $this->repository_url], [ + 'repository_url' => ['required', 'string', new ValidGitRepositoryUrl], + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); + } + if (str($this->repository_url)->startsWith('git@')) { $github_instance = str($this->repository_url)->after('git@')->before(':'); $repository = str($this->repository_url)->after(':')->before('.git'); @@ -191,6 +216,15 @@ class PublicGitRepository extends Component $this->git_branch = 'main'; $this->base_directory = '/'; + // Validate repository URL before parsing + $validator = validator(['repository_url' => $this->repository_url], [ + 'repository_url' => ['required', 'string', new ValidGitRepositoryUrl], + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); + } + $this->repository_url_parsed = Url::fromString($this->repository_url); $this->git_host = $this->repository_url_parsed->getHost(); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); @@ -234,6 +268,27 @@ class PublicGitRepository extends Component { try { $this->validate(); + + // Additional validation for git repository and branch + if ($this->git_source === 'other') { + // For 'other' sources, git_repository contains the full URL + $validator = validator(['git_repository' => $this->git_repository], [ + 'git_repository' => ['required', 'string', new ValidGitRepositoryUrl], + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('git_repository')); + } + } + + $branchValidator = validator(['git_branch' => $this->git_branch], [ + 'git_branch' => ['required', 'string', new ValidGitBranch], + ]); + + if ($branchValidator->fails()) { + throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch')); + } + $destination_uuid = $this->query['destination']; $project_uuid = $this->parameters['project_uuid']; $environment_uuid = $this->parameters['environment_uuid']; @@ -318,7 +373,7 @@ class PublicGitRepository extends Component $application->settings->is_static = $this->isStatic; $application->settings->save(); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->save(); if ($this->checkCoolifyConfig) { diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index ebc9878dc..9cc4fbbe2 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -68,7 +68,7 @@ CMD ["nginx", "-g", "daemon off;"] 'source_type' => GithubApp::class, ]); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->update([ 'name' => 'dockerfile-'.$application->uuid, 'fqdn' => $fqdn, diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index e7cff4f29..73960d288 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -15,6 +15,7 @@ class Create extends Component public function mount() { + $type = str(request()->query('type')); $destination_uuid = request()->query('destination'); $server_id = request()->query('server_id'); @@ -96,7 +97,6 @@ class Create extends Component 'value' => $value, 'resourceable_id' => $service->id, 'resourceable_type' => $service->getMorphClass(), - 'is_build_time' => false, 'is_preview' => false, ]); } diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index 8ac74e7de..559851e3a 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -3,11 +3,14 @@ namespace App\Livewire\Project\Service; use App\Models\Service; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; class Configuration extends Component { + use AuthorizesRequests; + public $currentRoute; public $project; @@ -40,24 +43,30 @@ class Configuration extends Component public function mount() { - $this->parameters = get_route_parameters(); - $this->currentRoute = request()->route()->getName(); - $this->query = request()->query(); - $project = currentTeam() - ->projects() - ->select('id', 'uuid', 'team_id') - ->where('uuid', request()->route('project_uuid')) - ->firstOrFail(); - $environment = $project->environments() - ->select('id', 'uuid', 'name', 'project_id') - ->where('uuid', request()->route('environment_uuid')) - ->firstOrFail(); - $this->service = $environment->services()->whereUuid(request()->route('service_uuid'))->firstOrFail(); + try { + $this->parameters = get_route_parameters(); + $this->currentRoute = request()->route()->getName(); + $this->query = request()->query(); + $project = currentTeam() + ->projects() + ->select('id', 'uuid', 'team_id') + ->where('uuid', request()->route('project_uuid')) + ->firstOrFail(); + $environment = $project->environments() + ->select('id', 'uuid', 'name', 'project_id') + ->where('uuid', request()->route('environment_uuid')) + ->firstOrFail(); + $this->service = $environment->services()->whereUuid(request()->route('service_uuid'))->firstOrFail(); - $this->project = $project; - $this->environment = $environment; - $this->applications = $this->service->applications->sort(); - $this->databases = $this->service->databases->sort(); + $this->authorize('view', $this->service); + + $this->project = $project; + $this->environment = $environment; + $this->applications = $this->service->applications->sort(); + $this->databases = $this->service->databases->sort(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function refreshServices() @@ -70,6 +79,7 @@ class Configuration extends Component public function restartApplication($id) { try { + $this->authorize('update', $this->service); $application = $this->service->applications->find($id); if ($application) { $application->restart(); @@ -83,6 +93,7 @@ class Configuration extends Component public function restartDatabase($id) { try { + $this->authorize('update', $this->service); $database = $this->service->databases->find($id); if ($database) { $database->restart(); diff --git a/app/Livewire/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php index 0af757c8c..abf4c45a7 100644 --- a/app/Livewire/Project/Service/Database.php +++ b/app/Livewire/Project/Service/Database.php @@ -6,6 +6,7 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; use App\Models\InstanceSettings; use App\Models\ServiceDatabase; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -13,6 +14,8 @@ use Livewire\Component; class Database extends Component { + use AuthorizesRequests; + public ServiceDatabase $database; public ?string $db_url_public = null; @@ -40,24 +43,31 @@ class Database extends Component public function mount() { - $this->parameters = get_route_parameters(); - if ($this->database->is_public) { - $this->db_url_public = $this->database->getServiceDatabaseUrl(); + try { + $this->parameters = get_route_parameters(); + $this->authorize('view', $this->database); + if ($this->database->is_public) { + $this->db_url_public = $this->database->getServiceDatabaseUrl(); + } + $this->refreshFileStorages(); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->refreshFileStorages(); } public function delete($password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } - } - try { + $this->authorize('delete', $this->database); + + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + } + $this->database->delete(); $this->dispatch('success', 'Database deleted.'); @@ -69,24 +79,35 @@ class Database extends Component public function instantSaveExclude() { - $this->submit(); + try { + $this->authorize('update', $this->database); + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function instantSaveLogDrain() { - if (! $this->database->service->destination->server->isLogDrainEnabled()) { - $this->database->is_log_drain_enabled = false; - $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); + try { + $this->authorize('update', $this->database); + if (! $this->database->service->destination->server->isLogDrainEnabled()) { + $this->database->is_log_drain_enabled = false; + $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); - return; + return; + } + $this->submit(); + $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->submit(); - $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } public function convertToApplication() { try { + $this->authorize('update', $this->database); $service = $this->database->service; $serviceDatabase = $this->database; @@ -122,28 +143,33 @@ class Database extends Component public function instantSave() { - if ($this->database->is_public && ! $this->database->public_port) { - $this->dispatch('error', 'Public port is required.'); - $this->database->is_public = false; - - return; - } - if ($this->database->is_public) { - if (! str($this->database->status)->startsWith('running')) { - $this->dispatch('error', 'Database must be started to be publicly accessible.'); + try { + $this->authorize('update', $this->database); + if ($this->database->is_public && ! $this->database->public_port) { + $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } - StartDatabaseProxy::run($this->database); - $this->db_url_public = $this->database->getServiceDatabaseUrl(); - $this->dispatch('success', 'Database is now publicly accessible.'); - } else { - StopDatabaseProxy::run($this->database); - $this->db_url_public = null; - $this->dispatch('success', 'Database is no longer publicly accessible.'); + if ($this->database->is_public) { + if (! str($this->database->status)->startsWith('running')) { + $this->dispatch('error', 'Database must be started to be publicly accessible.'); + $this->database->is_public = false; + + return; + } + StartDatabaseProxy::run($this->database); + $this->db_url_public = $this->database->getServiceDatabaseUrl(); + $this->dispatch('success', 'Database is now publicly accessible.'); + } else { + StopDatabaseProxy::run($this->database); + $this->db_url_public = null; + $this->dispatch('success', 'Database is no longer publicly accessible.'); + } + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->submit(); } public function refreshFileStorages() @@ -154,11 +180,13 @@ class Database extends Component public function submit() { try { + $this->authorize('update', $this->database); $this->validate(); $this->database->save(); updateCompose($this->database); $this->dispatch('success', 'Database saved.'); - } catch (\Throwable) { + } catch (\Throwable $e) { + return handleError($e, $this); } finally { $this->dispatch('generateDockerCompose'); } diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index b7f73159e..5ce170b99 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -12,6 +12,12 @@ class EditDomain extends Component public ServiceApplication $application; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + protected $rules = [ 'application.fqdn' => 'nullable', 'application.required_fqdn' => 'required|boolean', @@ -22,6 +28,13 @@ class EditDomain extends Component $this->application = ServiceApplication::find($this->applicationId); } + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); + } + public function submit() { try { @@ -37,7 +50,20 @@ class EditDomain extends Component if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } - check_domain_usage(resource: $this->application); + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + $this->validate(); $this->application->save(); updateCompose($this->application); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 5b88c15eb..2933a8cca 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -15,12 +15,15 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Component; class FileStorage extends Component { + use AuthorizesRequests; + public LocalFileVolume $fileStorage; public ServiceApplication|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase|Application $resource; @@ -54,6 +57,8 @@ class FileStorage extends Component public function convertToDirectory() { try { + $this->authorize('update', $this->resource); + $this->fileStorage->deleteStorageOnServer(); $this->fileStorage->is_directory = true; $this->fileStorage->content = null; @@ -70,6 +75,8 @@ class FileStorage extends Component public function loadStorageOnServer() { try { + $this->authorize('update', $this->resource); + $this->fileStorage->loadStorageOnServer(); $this->dispatch('success', 'File storage loaded from server.'); } catch (\Throwable $e) { @@ -82,6 +89,8 @@ class FileStorage extends Component public function convertToFile() { try { + $this->authorize('update', $this->resource); + $this->fileStorage->deleteStorageOnServer(); $this->fileStorage->is_directory = false; $this->fileStorage->content = null; @@ -99,6 +108,8 @@ class FileStorage extends Component public function delete($password) { + $this->authorize('update', $this->resource); + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { if (! Hash::check($password, Auth::user()->password)) { $this->addError('password', 'The provided password is incorrect.'); @@ -127,6 +138,8 @@ class FileStorage extends Component public function submit() { + $this->authorize('update', $this->resource); + $original = $this->fileStorage->getOriginal(); try { $this->validate(); diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 39f4e106d..8d37d3e31 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -5,11 +5,14 @@ namespace App\Livewire\Project\Service; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public ?Service $service = null; public ?ServiceApplication $serviceApplication = null; @@ -36,6 +39,7 @@ class Index extends Component if (! $this->service) { return redirect()->route('dashboard'); } + $this->authorize('view', $this->service); $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); if ($service) { $this->serviceApplication = $service; @@ -52,7 +56,12 @@ class Index extends Component public function generateDockerCompose() { - $this->service->parse(); + try { + $this->authorize('update', $this->service); + $this->service->parse(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function render() diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 64f7ab95c..3ac12cfe9 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Service; use App\Models\InstanceSettings; use App\Models\ServiceApplication; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; @@ -12,6 +13,8 @@ use Spatie\Url\Url; class ServiceApplicationView extends Component { + use AuthorizesRequests; + public ServiceApplication $application; public $parameters; @@ -20,6 +23,12 @@ class ServiceApplicationView extends Component public $delete_volumes = true; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + protected $rules = [ 'application.human_name' => 'nullable', 'application.description' => 'nullable', @@ -34,32 +43,44 @@ class ServiceApplicationView extends Component public function instantSave() { - $this->submit(); + try { + $this->authorize('update', $this->application); + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function instantSaveAdvanced() { - if (! $this->application->service->destination->server->isLogDrainEnabled()) { - $this->application->is_log_drain_enabled = false; - $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); + try { + $this->authorize('update', $this->application); + if (! $this->application->service->destination->server->isLogDrainEnabled()) { + $this->application->is_log_drain_enabled = false; + $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); - return; + return; + } + $this->application->save(); + $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->application->save(); - $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } public function delete($password) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } - } - try { + $this->authorize('delete', $this->application); + + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + } + $this->application->delete(); $this->dispatch('success', 'Application deleted.'); @@ -71,12 +92,18 @@ class ServiceApplicationView extends Component public function mount() { - $this->parameters = get_route_parameters(); + try { + $this->parameters = get_route_parameters(); + $this->authorize('view', $this->application); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function convertToDatabase() { try { + $this->authorize('update', $this->application); $service = $this->application->service; $serviceApplication = $this->application; @@ -108,9 +135,17 @@ class ServiceApplicationView extends Component } } + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); + } + public function submit() { try { + $this->authorize('update', $this->application); $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { @@ -123,7 +158,20 @@ class ServiceApplicationView extends Component if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } - check_domain_usage(resource: $this->application); + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + $this->validate(); $this->application->save(); updateCompose($this->application); diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index a67bd9210..1961a7985 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Service; use App\Models\Service; +use App\Support\ValidationPatterns; use Illuminate\Support\Collection; use Livewire\Component; @@ -14,13 +15,38 @@ class StackForm extends Component protected $listeners = ['saveCompose']; - public $rules = [ - 'service.docker_compose_raw' => 'required', - 'service.docker_compose' => 'required', - 'service.name' => 'required', - 'service.description' => 'nullable', - 'service.connect_to_docker_network' => 'nullable', - ]; + protected function rules(): array + { + $baseRules = [ + 'service.docker_compose_raw' => 'required', + 'service.docker_compose' => 'required', + 'service.name' => ValidationPatterns::nameRules(), + 'service.description' => ValidationPatterns::descriptionRules(), + 'service.connect_to_docker_network' => 'nullable', + ]; + + // Add dynamic field rules + foreach ($this->fields ?? collect() as $key => $field) { + $rules = data_get($field, 'rules', 'nullable'); + $baseRules["fields.$key.value"] = $rules; + } + + return $baseRules; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'service.name.required' => 'The Name field is required.', + 'service.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'service.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'service.docker_compose_raw.required' => 'The Docker Compose Raw field is required.', + 'service.docker_compose.required' => 'The Docker Compose field is required.', + ] + ); + } public $validationAttributes = []; @@ -45,7 +71,6 @@ class StackForm extends Component 'customHelper' => $customHelper, ]); - $this->rules["fields.$key.value"] = $rules; $this->validationAttributes["fields.$key.value"] = $fieldKey; } } diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 4b64a8b5e..26cd54425 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -3,10 +3,13 @@ namespace App\Livewire\Project\Service; use App\Models\LocalPersistentVolume; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Storage extends Component { + use AuthorizesRequests; + public $resource; public $fileStorage; @@ -42,6 +45,8 @@ class Storage extends Component public function addNewVolume($data) { try { + $this->authorize('update', $this->resource); + LocalPersistentVolume::create([ 'name' => $data['name'], 'mount_path' => $data['mount_path'], diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index ab9f3785d..ce9ce7780 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -20,7 +20,15 @@ class ConfigurationChecker extends Component public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; - protected $listeners = ['configurationChanged']; + public function getListeners() + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'configurationChanged', + 'configurationChanged' => 'configurationChanged', + ]; + } public function mount() { diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 7da48f9fb..0ed1347f8 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -7,6 +7,7 @@ use App\Models\InstanceSettings; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Component; @@ -14,6 +15,8 @@ use Visus\Cuid2\Cuid2; class Danger extends Component { + use AuthorizesRequests; + public $resource; public $resourceName; @@ -34,6 +37,8 @@ class Danger extends Component public string $resourceDomain = ''; + public bool $canDelete = false; + public function mount() { $parameters = get_route_parameters(); @@ -77,6 +82,13 @@ class Danger extends Component 'service-database' => $this->resource->name ?? 'Service Database', default => 'Unknown Resource', }; + + // Check if user can delete this resource + try { + $this->canDelete = auth()->user()->can('delete', $this->resource); + } catch (\Exception $e) { + $this->canDelete = false; + } } public function delete($password) @@ -96,13 +108,14 @@ class Danger extends Component } try { + $this->authorize('delete', $this->resource); $this->resource->delete(); DeleteResourceJob::dispatch( $this->resource, - $this->delete_configurations, $this->delete_volumes, - $this->docker_cleanup, - $this->delete_connected_networks + $this->delete_connected_networks, + $this->delete_configurations, + $this->docker_cleanup ); return redirect()->route('project.resource.index', [ diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 0dbf0f957..23a2cd59d 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -2,10 +2,13 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Add extends Component { + use AuthorizesRequests; + public $parameters; public bool $shared = false; @@ -16,28 +19,32 @@ class Add extends Component public ?string $value = null; - public bool $is_build_time = false; - public bool $is_multiline = false; public bool $is_literal = false; + public bool $is_runtime = true; + + public bool $is_buildtime = true; + protected $listeners = ['clearAddEnv' => 'clear']; protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', - 'is_build_time' => 'required|boolean', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', ]; protected $validationAttributes = [ 'key' => 'key', 'value' => 'value', - 'is_build_time' => 'build', 'is_multiline' => 'multiline', 'is_literal' => 'literal', + 'is_runtime' => 'runtime', + 'is_buildtime' => 'buildtime', ]; public function mount() @@ -51,9 +58,10 @@ class Add extends Component $this->dispatch('saveKey', [ 'key' => $this->key, 'value' => $this->value, - 'is_build_time' => $this->is_build_time, 'is_multiline' => $this->is_multiline, 'is_literal' => $this->is_literal, + 'is_runtime' => $this->is_runtime, + 'is_buildtime' => $this->is_buildtime, 'is_preview' => $this->is_preview, ]); $this->clear(); @@ -63,8 +71,9 @@ class Add extends Component { $this->key = ''; $this->value = ''; - $this->is_build_time = false; $this->is_multiline = false; $this->is_literal = false; + $this->is_runtime = true; + $this->is_buildtime = true; } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 3b6d8b937..639c025c7 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -4,11 +4,12 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; use App\Models\EnvironmentVariable; use App\Traits\EnvironmentVariableProtection; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class All extends Component { - use EnvironmentVariableProtection; + use AuthorizesRequests, EnvironmentVariableProtection; public $resource; @@ -24,6 +25,8 @@ class All extends Component public bool $is_env_sorting_enabled = false; + public bool $use_build_secrets = false; + protected $listeners = [ 'saveKey' => 'submit', 'refreshEnvs', @@ -33,43 +36,54 @@ class All extends Component public function mount() { $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); + $this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false); $this->resourceClass = get_class($this->resource); $resourceWithPreviews = [\App\Models\Application::class]; $simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) { $this->showPreview = true; } - $this->sortEnvironmentVariables(); + $this->getDevView(); } public function instantSave() { - $this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled; - $this->resource->settings->save(); - $this->sortEnvironmentVariables(); - $this->dispatch('success', 'Environment variable settings updated.'); + try { + $this->authorize('manageEnvironment', $this->resource); + + $this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled; + $this->resource->settings->use_build_secrets = $this->use_build_secrets; + $this->resource->settings->save(); + $this->getDevView(); + $this->dispatch('success', 'Environment variable settings updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } - public function sortEnvironmentVariables() + public function getEnvironmentVariablesProperty() { if ($this->is_env_sorting_enabled === false) { - if ($this->resource->environment_variables) { - $this->resource->environment_variables = $this->resource->environment_variables->sortBy('order')->values(); - } - - if ($this->resource->environment_variables_preview) { - $this->resource->environment_variables_preview = $this->resource->environment_variables_preview->sortBy('order')->values(); - } + return $this->resource->environment_variables()->orderBy('order')->get(); } - $this->getDevView(); + return $this->resource->environment_variables; + } + + public function getEnvironmentVariablesPreviewProperty() + { + if ($this->is_env_sorting_enabled === false) { + return $this->resource->environment_variables_preview()->orderBy('order')->get(); + } + + return $this->resource->environment_variables_preview; } public function getDevView() { - $this->variables = $this->formatEnvironmentVariables($this->resource->environment_variables); + $this->variables = $this->formatEnvironmentVariables($this->environmentVariables); if ($this->showPreview) { - $this->variablesPreview = $this->formatEnvironmentVariables($this->resource->environment_variables_preview); + $this->variablesPreview = $this->formatEnvironmentVariables($this->environmentVariablesPreview); } } @@ -90,12 +104,13 @@ class All extends Component public function switch() { $this->view = $this->view === 'normal' ? 'dev' : 'normal'; - $this->sortEnvironmentVariables(); + $this->getDevView(); } public function submit($data = null) { try { + $this->authorize('manageEnvironment', $this->resource); if ($data === null) { $this->handleBulkSubmit(); } else { @@ -103,7 +118,7 @@ class All extends Component } $this->updateOrder(); - $this->sortEnvironmentVariables(); + $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); } finally { @@ -204,9 +219,10 @@ class All extends Component $environment = new EnvironmentVariable; $environment->key = $data['key']; $environment->value = $data['value']; - $environment->is_build_time = $data['is_build_time'] ?? false; $environment->is_multiline = $data['is_multiline'] ?? false; $environment->is_literal = $data['is_literal'] ?? false; + $environment->is_runtime = $data['is_runtime'] ?? true; + $environment->is_buildtime = $data['is_buildtime'] ?? true; $environment->is_preview = $data['is_preview'] ?? false; $environment->resourceable_id = $this->resource->id; $environment->resourceable_type = $this->resource->getMorphClass(); @@ -249,7 +265,7 @@ class All extends Component { $count = 0; foreach ($variables as $key => $value) { - if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL')) { + if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) { continue; } $method = $isPreview ? 'environment_variables_preview' : 'environment_variables'; @@ -268,7 +284,6 @@ class All extends Component $environment = new EnvironmentVariable; $environment->key = $key; $environment->value = $value; - $environment->is_build_time = false; $environment->is_multiline = false; $environment->is_preview = $isPreview; $environment->resourceable_id = $this->resource->id; @@ -285,7 +300,6 @@ class All extends Component public function refreshEnvs() { $this->resource->refresh(); - $this->sortEnvironmentVariables(); $this->getDevView(); } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 966d626b1..0d0467c13 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -5,11 +5,12 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Models\SharedEnvironmentVariable; use App\Traits\EnvironmentVariableProtection; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { - use EnvironmentVariableProtection; + use AuthorizesRequests, EnvironmentVariableProtection; public $parameters; @@ -31,14 +32,16 @@ class Show extends Component public bool $is_shared = false; - public bool $is_build_time = false; - public bool $is_multiline = false; public bool $is_literal = false; public bool $is_shown_once = false; + public bool $is_runtime = true; + + public bool $is_buildtime = true; + public bool $is_required = false; public bool $is_really_required = false; @@ -54,10 +57,11 @@ class Show extends Component protected $rules = [ 'key' => 'required|string', 'value' => 'nullable', - 'is_build_time' => 'required|boolean', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', 'real_value' => 'nullable', 'is_required' => 'required|boolean', ]; @@ -75,6 +79,11 @@ class Show extends Component } } + public function getResourceProperty() + { + return $this->env->resourceable ?? $this->env; + } + public function refresh() { $this->syncData(); @@ -95,8 +104,9 @@ class Show extends Component ]); } else { $this->validate(); - $this->env->is_build_time = $this->is_build_time; $this->env->is_required = $this->is_required; + $this->env->is_runtime = $this->is_runtime; + $this->env->is_buildtime = $this->is_buildtime; $this->env->is_shared = $this->is_shared; } $this->env->key = $this->key; @@ -108,10 +118,11 @@ class Show extends Component } else { $this->key = $this->env->key; $this->value = $this->env->value; - $this->is_build_time = $this->env->is_build_time ?? false; $this->is_multiline = $this->env->is_multiline; $this->is_literal = $this->env->is_literal; $this->is_shown_once = $this->env->is_shown_once; + $this->is_runtime = $this->env->is_runtime ?? true; + $this->is_buildtime = $this->env->is_buildtime ?? true; $this->is_required = $this->env->is_required ?? false; $this->is_really_required = $this->env->is_really_required ?? false; $this->is_shared = $this->env->is_shared ?? false; @@ -122,7 +133,7 @@ class Show extends Component public function checkEnvs() { $this->isDisabled = false; - if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL')) { + if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) { $this->isDisabled = true; } if ($this->env->is_shown_once) { @@ -133,13 +144,12 @@ class Show extends Component public function serialize() { data_forget($this->env, 'real_value'); - if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) { - data_forget($this->env, 'is_build_time'); - } } public function lock() { + $this->authorize('update', $this->env); + $this->env->is_shown_once = true; if ($this->isSharedVariable) { unset($this->env->is_required); @@ -158,6 +168,8 @@ class Show extends Component public function submit() { try { + $this->authorize('update', $this->env); + if (! $this->isSharedVariable && $this->is_required && str($this->value)->isEmpty()) { $oldValue = $this->env->getOriginal('value'); $this->value = $oldValue; @@ -179,9 +191,11 @@ class Show extends Component public function delete() { try { + $this->authorize('delete', $this->env); + // Check if the variable is used in Docker Compose - if ($this->type === 'service' || $this->type === 'application' && $this->env->resource()?->docker_compose) { - [$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($this->env->key, $this->env->resource()?->docker_compose); + if ($this->type === 'service' || $this->type === 'application' && $this->env->resourceable?->docker_compose) { + [$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($this->env->key, $this->env->resourceable?->docker_compose); if ($isUsed) { $this->dispatch('error', "Cannot delete environment variable '{$this->env->key}'

Please remove it from the Docker Compose file first."); diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 2d55807c7..02062e1f7 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -33,9 +33,6 @@ class ExecuteContainerCommand extends Component public function mount() { - if (! auth()->user()->isAdmin()) { - abort(403); - } $this->parameters = get_route_parameters(); $this->containers = collect(); $this->servers = collect(); @@ -132,6 +129,12 @@ class ExecuteContainerCommand extends Component }); } } + + // Sort containers alphabetically by name + $this->containers = $this->containers->sortBy(function ($container) { + return data_get($container, 'container.Names'); + }); + if ($this->containers->count() === 1) { $this->selected_container = data_get($this->containers->first(), 'container.Names'); } diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index 83162e36a..ae94f7cf2 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -2,10 +2,13 @@ namespace App\Livewire\Project\Shared; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class HealthChecks extends Component { + use AuthorizesRequests; + public $resource; protected $rules = [ @@ -27,6 +30,7 @@ class HealthChecks extends Component public function instantSave() { + $this->authorize('update', $this->resource); $this->resource->save(); $this->dispatch('success', 'Health check updated.'); } @@ -34,6 +38,7 @@ class HealthChecks extends Component public function submit() { try { + $this->authorize('update', $this->resource); $this->validate(); $this->resource->save(); $this->dispatch('success', 'Health check updated.'); diff --git a/app/Livewire/Project/Shared/Metrics.php b/app/Livewire/Project/Shared/Metrics.php index fdc35fc0f..e5b87b48c 100644 --- a/app/Livewire/Project/Shared/Metrics.php +++ b/app/Livewire/Project/Shared/Metrics.php @@ -8,7 +8,7 @@ class Metrics extends Component { public $resource; - public $chartId = 'container-cpu'; + public $chartId = 'metrics'; public $data; diff --git a/app/Livewire/Project/Shared/ResourceLimits.php b/app/Livewire/Project/Shared/ResourceLimits.php index 608dfbf02..196badec8 100644 --- a/app/Livewire/Project/Shared/ResourceLimits.php +++ b/app/Livewire/Project/Shared/ResourceLimits.php @@ -2,10 +2,13 @@ namespace App\Livewire\Project\Shared; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class ResourceLimits extends Component { + use AuthorizesRequests; + public $resource; protected $rules = [ @@ -31,6 +34,7 @@ class ResourceLimits extends Component public function submit() { try { + $this->authorize('update', $this->resource); if (! $this->resource->limits_memory) { $this->resource->limits_memory = '0'; } diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index fb19acb55..47b3534a2 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -2,7 +2,6 @@ namespace App\Livewire\Project\Shared; -use App\Actions\Application\StopApplication; use App\Actions\Database\StartDatabase; use App\Actions\Database\StopDatabase; use App\Actions\Service\StartService; @@ -12,11 +11,14 @@ use App\Models\Environment; use App\Models\Project; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Visus\Cuid2\Cuid2; class ResourceOperations extends Component { + use AuthorizesRequests; + public $resource; public $projectUuid; @@ -45,6 +47,8 @@ class ResourceOperations extends Component public function cloneTo($destination_id) { + $this->authorize('update', $this->resource); + $new_destination = StandaloneDocker::find($destination_id); if (! $new_destination) { $new_destination = SwarmDocker::find($destination_id); @@ -56,145 +60,7 @@ class ResourceOperations extends Component $server = $new_destination->server; if ($this->resource->getMorphClass() === \App\Models\Application::class) { - $name = 'clone-of-'.str($this->resource->name)->limit(20).'-'.$uuid; - $applicationSettings = $this->resource->settings; - $url = $this->resource->fqdn; - - if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $url = generateFqdn($server, $uuid); - } - - $new_resource = $this->resource->replicate([ - 'id', - 'created_at', - 'updated_at', - 'additional_servers_count', - 'additional_networks_count', - ])->fill([ - 'uuid' => $uuid, - 'name' => $name, - 'fqdn' => $url, - 'status' => 'exited', - 'destination_id' => $new_destination->id, - ]); - $new_resource->save(); - - if ($new_resource->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $customLabels = str(implode('|coolify|', generateLabelsApplication($new_resource)))->replace('|coolify|', "\n"); - $new_resource->custom_labels = base64_encode($customLabels); - $new_resource->save(); - } - - $new_resource->settings()->delete(); - if ($applicationSettings) { - $newApplicationSettings = $applicationSettings->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $new_resource->id, - ]); - $newApplicationSettings->save(); - } - - $tags = $this->resource->tags; - foreach ($tags as $tag) { - $new_resource->tags()->attach($tag->id); - } - - $scheduledTasks = $this->resource->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => (string) new Cuid2, - 'application_id' => $new_resource->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $applicationPreviews = $this->resource->previews()->get(); - foreach ($applicationPreviews as $preview) { - $newPreview = $preview->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'application_id' => $new_resource->id, - 'status' => 'exited', - ]); - $newPreview->save(); - } - - $persistentVolumes = $this->resource->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $this->resource->uuid)) { - $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid); - } else { - $newName = $new_resource->uuid.'-'.str($volume->name)->afterLast('-'); - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'name' => $newName, - 'resource_id' => $new_resource->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopApplication::dispatch($this->resource, false, false); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $this->resource->destination->server; - $targetServer = $new_resource->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - queue_application_deployment( - deployment_uuid: (string) new Cuid2, - application: $this->resource, - server: $sourceServer, - destination: $this->resource->destination, - no_questions_asked: true - ); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $this->resource->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $new_resource->id, - ]); - $newStorage->save(); - } - - $environmentVaribles = $this->resource->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $new_resource->id, - 'resourceable_type' => $new_resource->getMorphClass(), - ]); - $newEnvironmentVariable->save(); - } + $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); $route = route('project.application.configuration', [ 'project_uuid' => $this->projectUuid, @@ -412,7 +278,7 @@ class ResourceOperations extends Component if ($this->cloneVolumeData) { try { - StopService::dispatch($application, false, false); + StopService::dispatch($application); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; $sourceServer = $application->service->destination->server; @@ -454,7 +320,7 @@ class ResourceOperations extends Component if ($this->cloneVolumeData) { try { - StopService::dispatch($database->service, false, false); + StopService::dispatch($database->service); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; $sourceServer = $database->service->destination->server; @@ -485,6 +351,7 @@ class ResourceOperations extends Component public function moveTo($environment_id) { try { + $this->authorize('update', $this->resource); $new_environment = Environment::findOrFail($environment_id); $this->resource->update([ 'environment_id' => $environment_id, diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index c286fee5a..e4b666532 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -3,12 +3,15 @@ namespace App\Livewire\Project\Shared\ScheduledTask; use App\Models\ScheduledTask; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; use Livewire\Component; class Add extends Component { + use AuthorizesRequests; + public $parameters; #[Locked] @@ -20,6 +23,9 @@ class Add extends Component #[Locked] public Collection $containerNames; + #[Locked] + public $resource; + public string $name; public string $command; @@ -45,6 +51,22 @@ class Add extends Component public function mount() { $this->parameters = get_route_parameters(); + + // Get the resource based on type and id + switch ($this->type) { + case 'application': + $this->resource = \App\Models\Application::findOrFail($this->id); + break; + case 'service': + $this->resource = \App\Models\Service::findOrFail($this->id); + break; + case 'standalone-postgresql': + $this->resource = \App\Models\StandalonePostgresql::findOrFail($this->id); + break; + default: + throw new \Exception('Invalid resource type'); + } + if ($this->containerNames->count() > 0) { $this->container = $this->containerNames->first(); } @@ -53,6 +75,7 @@ class Add extends Component public function submit() { try { + $this->authorize('update', $this->resource); $this->validate(); $isValid = validate_cron_expression($this->frequency); if (! $isValid) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php index 6f62a5b5b..ca2bbd9b4 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -105,6 +105,19 @@ class Executions extends Component $this->currentPage++; } + public function loadAllLogs() + { + if (! $this->selectedExecution || ! $this->selectedExecution->message) { + return; + } + + $lines = collect(explode("\n", $this->selectedExecution->message)); + $totalLines = $lines->count(); + $totalPages = ceil($totalLines / $this->logsPerPage); + + $this->currentPage = $totalPages; + } + public function getLogLinesProperty() { if (! $this->selectedExecution) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index fe6e36d5c..c8d07ae36 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -6,12 +6,15 @@ use App\Jobs\ScheduledTaskJob; use App\Models\Application; use App\Models\ScheduledTask; use App\Models\Service; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public Application|Service $resource; public ScheduledTask $task; @@ -109,6 +112,7 @@ class Show extends Component public function instantSave() { try { + $this->authorize('update', $this->resource); $this->syncData(true); $this->dispatch('success', 'Scheduled task updated.'); $this->refreshTasks(); @@ -120,6 +124,7 @@ class Show extends Component public function submit() { try { + $this->authorize('update', $this->resource); $this->syncData(true); $this->dispatch('success', 'Scheduled task updated.'); } catch (\Exception $e) { @@ -139,6 +144,7 @@ class Show extends Component public function delete() { try { + $this->authorize('update', $this->resource); $this->task->delete(); if ($this->type === 'application') { @@ -154,6 +160,7 @@ class Show extends Component public function executeNow() { try { + $this->authorize('update', $this->resource); ScheduledTaskJob::dispatch($this->task); $this->dispatch('success', 'Scheduled task executed.'); } catch (\Exception $e) { diff --git a/app/Livewire/Project/Shared/Storages/Add.php b/app/Livewire/Project/Shared/Storages/Add.php index dc015386c..006d41c14 100644 --- a/app/Livewire/Project/Shared/Storages/Add.php +++ b/app/Livewire/Project/Shared/Storages/Add.php @@ -4,10 +4,13 @@ namespace App\Livewire\Project\Shared\Storages; use App\Models\Application; use App\Models\LocalFileVolume; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Add extends Component { + use AuthorizesRequests; + public $resource; public $uuid; @@ -77,6 +80,8 @@ class Add extends Component public function submitFileStorage() { try { + $this->authorize('update', $this->resource); + $this->validate([ 'file_storage_path' => 'string', 'file_storage_content' => 'nullable|string', @@ -112,6 +117,8 @@ class Add extends Component public function submitFileStorageDirectory() { try { + $this->authorize('update', $this->resource); + $this->validate([ 'file_storage_directory_source' => 'string', 'file_storage_directory_destination' => 'string', @@ -140,6 +147,8 @@ class Add extends Component public function submitPersistentVolume() { try { + $this->authorize('update', $this->resource); + $this->validate([ 'name' => 'required|string', 'mount_path' => 'required|string', diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index c26315d3b..63fc06a36 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -9,4 +9,15 @@ class All extends Component public $resource; protected $listeners = ['refreshStorages' => '$refresh']; + + public function getFirstStorageIdProperty() + { + if ($this->resource->persistentStorages->isEmpty()) { + return null; + } + + // Use the storage with the smallest ID as the "first" one + // This ensures stability even when storages are deleted + return $this->resource->persistentStorages->sortBy('id')->first()->id; + } } diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 54b1be3af..3928ee1d4 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -4,14 +4,19 @@ namespace App\Livewire\Project\Shared\Storages; use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public LocalPersistentVolume $storage; + public $resource; + public bool $isReadOnly = false; public bool $isFirst = true; @@ -34,6 +39,8 @@ class Show extends Component public function submit() { + $this->authorize('update', $this->resource); + $this->validate(); $this->storage->save(); $this->dispatch('success', 'Storage updated successfully'); @@ -41,6 +48,8 @@ class Show extends Component public function delete($password) { + $this->authorize('update', $this->resource); + if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { if (! Hash::check($password, Auth::user()->password)) { $this->addError('password', 'The provided password is incorrect.'); diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 811859cb8..37b8b277a 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -3,12 +3,15 @@ namespace App\Livewire\Project\Shared; use App\Models\Tag; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; // Refactored ✅ class Tags extends Component { + use AuthorizesRequests; + public $resource = null; #[Validate('required|string|min:2')] @@ -34,6 +37,7 @@ class Tags extends Component public function submit() { try { + $this->authorize('update', $this->resource); $this->validate(); $tags = str($this->newTags)->trim()->explode(' '); foreach ($tags as $tag) { @@ -66,6 +70,7 @@ class Tags extends Component public function addTag(string $id, string $name) { try { + $this->authorize('update', $this->resource); $name = strip_tags($name); if ($this->resource->tags()->where('id', $id)->exists()) { $this->dispatch('error', 'Duplicate tags.', "Tag $name already added."); @@ -83,6 +88,7 @@ class Tags extends Component public function deleteTag(string $id) { try { + $this->authorize('update', $this->resource); $this->resource->tags()->detach($id); $found_more_tags = Tag::ownedByCurrentTeam()->find($id); if ($found_more_tags && $found_more_tags->applications()->count() == 0 && $found_more_tags->services()->count() == 0) { diff --git a/app/Livewire/Project/Shared/Webhooks.php b/app/Livewire/Project/Shared/Webhooks.php index 57c65c4dd..eafc653d5 100644 --- a/app/Livewire/Project/Shared/Webhooks.php +++ b/app/Livewire/Project/Shared/Webhooks.php @@ -2,11 +2,14 @@ namespace App\Livewire\Project\Shared; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; // Refactored ✅ class Webhooks extends Component { + use AuthorizesRequests; + public $resource; public ?string $deploywebhook; diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index 886a20218..7e828d14c 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -4,7 +4,7 @@ namespace App\Livewire\Project; use App\Models\Environment; use App\Models\Project; -use Livewire\Attributes\Validate; +use App\Support\ValidationPatterns; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -12,12 +12,23 @@ class Show extends Component { public Project $project; - #[Validate(['required', 'string', 'min:3'])] public string $name; - #[Validate(['nullable', 'string'])] public ?string $description = null; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + public function mount(string $project_uuid) { try { diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index 72684bdc6..a263acedf 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -3,10 +3,14 @@ namespace App\Livewire\Security; use App\Models\InstanceSettings; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Laravel\Sanctum\PersonalAccessToken; use Livewire\Component; class ApiTokens extends Component { + use AuthorizesRequests; + public ?string $description = null; public $tokens = []; @@ -15,6 +19,10 @@ class ApiTokens extends Component public $isApiEnabled; + public bool $canUseRootPermissions = false; + + public bool $canUseWritePermissions = false; + public function render() { return view('livewire.security.api-tokens'); @@ -23,6 +31,8 @@ class ApiTokens extends Component public function mount() { $this->isApiEnabled = InstanceSettings::get()->is_api_enabled; + $this->canUseRootPermissions = auth()->user()->can('useRootPermissions', PersonalAccessToken::class); + $this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class); $this->getTokens(); } @@ -33,6 +43,23 @@ class ApiTokens extends Component public function updatedPermissions($permissionToUpdate) { + // Check if user is trying to use restricted permissions + if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) { + $this->dispatch('error', 'You do not have permission to use root permissions.'); + // Remove root from permissions if it was somehow added + $this->permissions = array_diff($this->permissions, ['root']); + + return; + } + + if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) { + $this->dispatch('error', 'You do not have permission to use write permissions.'); + // Remove write permissions if they were somehow added + $this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']); + + return; + } + if ($permissionToUpdate == 'root') { $this->permissions = ['root']; } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) { @@ -50,6 +77,17 @@ class ApiTokens extends Component public function addNewToken() { try { + $this->authorize('create', PersonalAccessToken::class); + + // Validate permissions based on user role + if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) { + throw new \Exception('You do not have permission to create tokens with root permissions.'); + } + + if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) { + throw new \Exception('You do not have permission to create tokens with write permissions.'); + } + $this->validate([ 'description' => 'required|min:3|max:255', ]); @@ -65,6 +103,7 @@ class ApiTokens extends Component { try { $token = auth()->user()->tokens()->where('id', $id)->firstOrFail(); + $this->authorize('delete', $token); $token->delete(); $this->getTokens(); } catch (\Exception $e) { diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php index 319cec192..0f36037ff 100644 --- a/app/Livewire/Security/PrivateKey/Create.php +++ b/app/Livewire/Security/PrivateKey/Create.php @@ -3,10 +3,14 @@ namespace App\Livewire\Security\PrivateKey; use App\Models\PrivateKey; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Create extends Component { + use AuthorizesRequests; + public string $name = ''; public string $value = ''; @@ -17,10 +21,25 @@ class Create extends Component public ?string $publicKey = null; - protected $rules = [ - 'name' => 'required|string', - 'value' => 'required|string', - ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'value' => 'required|string', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'value.required' => 'The Private Key field is required.', + 'value.string' => 'The Private Key must be a valid string.', + ] + ); + } public function generateNewRSAKey() { @@ -50,6 +69,7 @@ class Create extends Component $this->validate(); try { + $this->authorize('create', PrivateKey::class); $privateKey = PrivateKey::createAndStore([ 'name' => $this->name, 'description' => $this->description, diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 76441a67e..950ec152d 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -3,10 +3,13 @@ namespace App\Livewire\Security\PrivateKey; use App\Models\PrivateKey; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public function render() { $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description'])->get(); @@ -18,6 +21,7 @@ class Index extends Component public function cleanupUnusedKeys() { + $this->authorize('create', PrivateKey::class); PrivateKey::cleanupUnusedKeys(); $this->dispatch('success', 'Unused keys have been cleaned up.'); } diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index b9195b543..2ff06c349 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -3,20 +3,41 @@ namespace App\Livewire\Security\PrivateKey; use App\Models\PrivateKey; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public PrivateKey $private_key; public $public_key = 'Loading...'; - protected $rules = [ - 'private_key.name' => 'required|string', - 'private_key.description' => 'nullable|string', - 'private_key.private_key' => 'required|string', - 'private_key.is_git_related' => 'nullable|boolean', - ]; + protected function rules(): array + { + return [ + 'private_key.name' => ValidationPatterns::nameRules(), + 'private_key.description' => ValidationPatterns::descriptionRules(), + 'private_key.private_key' => 'required|string', + 'private_key.is_git_related' => 'nullable|boolean', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'private_key.name.required' => 'The Name field is required.', + 'private_key.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'private_key.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'private_key.private_key.required' => 'The Private Key field is required.', + 'private_key.private_key.string' => 'The Private Key must be a valid string.', + ] + ); + } protected $validationAttributes = [ 'private_key.name' => 'name', @@ -44,6 +65,7 @@ class Show extends Component public function delete() { try { + $this->authorize('delete', $this->private_key); $this->private_key->safeDelete(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); @@ -58,6 +80,7 @@ class Show extends Component public function changePrivateKey() { try { + $this->authorize('update', $this->private_key); $this->private_key->updatePrivateKey([ 'private_key' => formatPrivateKey($this->private_key->private_key), ]); diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index 1bf8cf4c9..760c4df0d 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -76,6 +76,7 @@ class Advanced extends Component public function syncData(bool $toModel = false) { if ($toModel) { + $this->authorize('update', $this->server); $this->validate(); $this->server->settings->concurrent_builds = $this->concurrentBuilds; $this->server->settings->dynamic_timeout = $this->dynamicTimeout; diff --git a/app/Livewire/Server/CaCertificate/Show.php b/app/Livewire/Server/CaCertificate/Show.php index 750ed9f81..039b5f71d 100644 --- a/app/Livewire/Server/CaCertificate/Show.php +++ b/app/Livewire/Server/CaCertificate/Show.php @@ -6,12 +6,15 @@ use App\Helpers\SslHelper; use App\Jobs\RegenerateSslCertJob; use App\Models\Server; use App\Models\SslCertificate; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Carbon; use Livewire\Attributes\Locked; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + #[Locked] public Server $server; @@ -52,6 +55,7 @@ class Show extends Component public function saveCaCertificate() { try { + $this->authorize('manageCaCertificate', $this->server); if (! $this->certificateContent) { throw new \Exception('Certificate content cannot be empty.'); } @@ -82,6 +86,7 @@ class Show extends Component public function regenerateCaCertificate() { try { + $this->authorize('manageCaCertificate', $this->server); SslHelper::generateSslCertificate( commonName: 'Coolify CA Certificate', serverId: $this->server->id, diff --git a/app/Livewire/Server/CloudflareTunnel.php b/app/Livewire/Server/CloudflareTunnel.php index b2ffa003f..24f8e022e 100644 --- a/app/Livewire/Server/CloudflareTunnel.php +++ b/app/Livewire/Server/CloudflareTunnel.php @@ -4,11 +4,14 @@ namespace App\Livewire\Server; use App\Actions\Server\ConfigureCloudflared; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class CloudflareTunnel extends Component { + use AuthorizesRequests; + public Server $server; #[Validate(['required', 'string'])] @@ -51,6 +54,7 @@ class CloudflareTunnel extends Component public function toggleCloudflareTunnels() { try { + $this->authorize('update', $this->server); remote_process(['docker rm -f coolify-cloudflared'], $this->server, false, 10); $this->isCloudflareTunnelsEnabled = false; $this->server->settings->is_cloudflare_tunnel = false; @@ -68,6 +72,7 @@ class CloudflareTunnel extends Component public function manualCloudflareConfig() { + $this->authorize('update', $this->server); $this->isCloudflareTunnelsEnabled = true; $this->server->settings->is_cloudflare_tunnel = true; $this->server->settings->save(); @@ -78,6 +83,7 @@ class CloudflareTunnel extends Component public function automatedCloudflareConfig() { try { + $this->authorize('update', $this->server); if (str($this->ssh_domain)->contains('https://')) { $this->ssh_domain = str($this->ssh_domain)->replace('https://', '')->replace('http://', '')->trim(); $this->ssh_domain = str($this->ssh_domain)->replace('/', ''); diff --git a/app/Livewire/Server/Destinations.php b/app/Livewire/Server/Destinations.php index dbab6e03f..3dbb3fcf8 100644 --- a/app/Livewire/Server/Destinations.php +++ b/app/Livewire/Server/Destinations.php @@ -5,11 +5,14 @@ namespace App\Livewire\Server; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; class Destinations extends Component { + use AuthorizesRequests; + public Server $server; public Collection $networks; @@ -33,6 +36,7 @@ class Destinations extends Component public function add($name) { if ($this->server->isSwarm()) { + $this->authorize('create', SwarmDocker::class); $found = $this->server->swarmDockers()->where('network', $name)->first(); if ($found) { $this->dispatch('error', 'Network already added to this server.'); @@ -46,6 +50,7 @@ class Destinations extends Component ]); } } else { + $this->authorize('create', StandaloneDocker::class); $found = $this->server->standaloneDockers()->where('network', $name)->first(); if ($found) { $this->dispatch('error', 'Network already added to this server.'); diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index d3378d63f..764e583cd 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -4,11 +4,14 @@ namespace App\Livewire\Server; use App\Jobs\DockerCleanupJob; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class DockerCleanup extends Component { + use AuthorizesRequests; + public Server $server; public array $parameters = []; @@ -42,6 +45,7 @@ class DockerCleanup extends Component public function syncData(bool $toModel = false) { if ($toModel) { + $this->authorize('update', $this->server); $this->validate(); $this->server->settings->force_docker_cleanup = $this->forceDockerCleanup; $this->server->settings->docker_cleanup_frequency = $this->dockerCleanupFrequency; @@ -71,7 +75,8 @@ class DockerCleanup extends Component public function manualCleanup() { try { - DockerCleanupJob::dispatch($this->server, true); + $this->authorize('update', $this->server); + DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks); $this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index edddfc755..d4a65af81 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -5,11 +5,14 @@ namespace App\Livewire\Server; use App\Actions\Server\StartLogDrain; use App\Actions\Server\StopLogDrain; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; class LogDrains extends Component { + use AuthorizesRequests; + public Server $server; #[Validate(['boolean'])] @@ -160,6 +163,7 @@ class LogDrains extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->syncData(true); if ($this->server->isLogDrainEnabled()) { StartLogDrain::run($this->server); @@ -176,6 +180,7 @@ class LogDrains extends Component public function submit(string $type) { try { + $this->authorize('update', $this->server); $this->syncData(true, $type); $this->dispatch('success', 'Settings saved.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 5381d1e19..beefed12a 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -8,10 +8,13 @@ use App\Actions\Proxy\StopProxy; use App\Jobs\RestartProxyJob; use App\Models\Server; use App\Services\ProxyDashboardCacheService; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Navbar extends Component { + use AuthorizesRequests; + public Server $server; public bool $isChecking = false; @@ -29,7 +32,7 @@ class Navbar extends Component $teamId = auth()->user()->currentTeam()->id; return [ - 'refreshServerShow' => '$refresh', + 'refreshServerShow' => 'refreshServer', "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', ]; } @@ -57,6 +60,7 @@ class Navbar extends Component public function restart() { try { + $this->authorize('manageProxy', $this->server); RestartProxyJob::dispatch($this->server); } catch (\Throwable $e) { return handleError($e, $this); @@ -66,6 +70,7 @@ class Navbar extends Component public function checkProxy() { try { + $this->authorize('manageProxy', $this->server); CheckProxy::run($this->server, true); $this->dispatch('startProxy')->self(); } catch (\Throwable $e) { @@ -76,6 +81,7 @@ class Navbar extends Component public function startProxy() { try { + $this->authorize('manageProxy', $this->server); $activity = StartProxy::run($this->server, force: true); $this->dispatch('activityMonitor', $activity->id); } catch (\Throwable $e) { @@ -86,6 +92,7 @@ class Navbar extends Component public function stop(bool $forceStop = true) { try { + $this->authorize('manageProxy', $this->server); StopProxy::dispatch($this->server, $forceStop); } catch (\Throwable $e) { return handleError($e, $this); @@ -127,6 +134,12 @@ class Navbar extends Component } + public function refreshServer() + { + $this->server->refresh(); + $this->server->load('settings'); + } + public function render() { return view('livewire.server.navbar'); diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 5f60c5db5..116775a6f 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -5,56 +5,46 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; use App\Models\Server; use App\Models\Team; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; -use Livewire\Attributes\Validate; use Livewire\Component; class ByIp extends Component { + use AuthorizesRequests; + #[Locked] public $private_keys; #[Locked] public $limit_reached; - #[Validate('nullable|integer', as: 'Private Key')] public ?int $private_key_id = null; - #[Validate('nullable|string', as: 'Private Key Name')] public $new_private_key_name; - #[Validate('nullable|string', as: 'Private Key Description')] public $new_private_key_description; - #[Validate('nullable|string', as: 'Private Key Value')] public $new_private_key_value; - #[Validate('required|string', as: 'Name')] public string $name; - #[Validate('nullable|string', as: 'Description')] public ?string $description = null; - #[Validate('required|string', as: 'IP Address/Domain')] public string $ip; - #[Validate('required|string', as: 'User')] public string $user = 'root'; - #[Validate('required|integer|between:1,65535', as: 'Port')] public int $port = 22; - #[Validate('required|boolean', as: 'Swarm Manager')] public bool $is_swarm_manager = false; - #[Validate('required|boolean', as: 'Swarm Worker')] public bool $is_swarm_worker = false; - #[Validate('nullable|integer', as: 'Swarm Cluster')] public $selected_swarm_cluster = null; - #[Validate('required|boolean', as: 'Build Server')] public bool $is_build_server = false; #[Locked] @@ -70,6 +60,50 @@ class ByIp extends Component } } + protected function rules(): array + { + return [ + 'private_key_id' => 'nullable|integer', + 'new_private_key_name' => 'nullable|string', + 'new_private_key_description' => 'nullable|string', + 'new_private_key_value' => 'nullable|string', + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'ip' => 'required|string', + 'user' => 'required|string', + 'port' => 'required|integer|between:1,65535', + 'is_swarm_manager' => 'required|boolean', + 'is_swarm_worker' => 'required|boolean', + 'selected_swarm_cluster' => 'nullable|integer', + 'is_build_server' => 'required|boolean', + ]; + } + + protected function messages(): array + { + return array_merge(ValidationPatterns::combinedMessages(), [ + 'private_key_id.integer' => 'The Private Key field must be an integer.', + 'private_key_id.nullable' => 'The Private Key field is optional.', + 'new_private_key_name.string' => 'The Private Key Name must be a string.', + 'new_private_key_description.string' => 'The Private Key Description must be a string.', + 'new_private_key_value.string' => 'The Private Key Value must be a string.', + 'ip.required' => 'The IP Address/Domain is required.', + 'ip.string' => 'The IP Address/Domain must be a string.', + 'user.required' => 'The User field is required.', + 'user.string' => 'The User field must be a string.', + 'port.required' => 'The Port field is required.', + 'port.integer' => 'The Port field must be an integer.', + 'port.between' => 'The Port field must be between 1 and 65535.', + 'is_swarm_manager.required' => 'The Swarm Manager field is required.', + 'is_swarm_manager.boolean' => 'The Swarm Manager field must be true or false.', + 'is_swarm_worker.required' => 'The Swarm Worker field is required.', + 'is_swarm_worker.boolean' => 'The Swarm Worker field must be true or false.', + 'selected_swarm_cluster.integer' => 'The Swarm Cluster field must be an integer.', + 'is_build_server.required' => 'The Build Server field is required.', + 'is_build_server.boolean' => 'The Build Server field must be true or false.', + ]); + } + public function setPrivateKey(string $private_key_id) { $this->private_key_id = $private_key_id; @@ -84,6 +118,7 @@ class ByIp extends Component { $this->validate(); try { + $this->authorize('create', Server::class); if (Server::where('team_id', currentTeam()->id) ->where('ip', $this->ip) ->exists()) { diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 64aa1884b..845d568ce 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -4,10 +4,13 @@ namespace App\Livewire\Server\PrivateKey; use App\Models\PrivateKey; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public Server $server; public $privateKeys = []; @@ -35,6 +38,7 @@ class Show extends Component $originalPrivateKeyId = $this->server->getOriginal('private_key_id'); try { + $this->authorize('update', $this->server); $this->server->update(['private_key_id' => $privateKeyId]); ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(justCheckingNewKey: true); if ($uptime) { diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 1cf8c839e..5ef559862 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -2,22 +2,25 @@ namespace App\Livewire\Server; -use App\Actions\Proxy\CheckConfiguration; -use App\Actions\Proxy\SaveConfiguration; +use App\Actions\Proxy\GetProxyConfiguration; +use App\Actions\Proxy\SaveProxyConfiguration; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Proxy extends Component { + use AuthorizesRequests; + public Server $server; public ?string $selectedProxy = null; - public $proxy_settings = null; + public $proxySettings = null; - public bool $redirect_enabled = true; + public bool $redirectEnabled = true; - public ?string $redirect_url = null; + public ?string $redirectUrl = null; public function getListeners() { @@ -36,17 +39,18 @@ class Proxy extends Component public function mount() { $this->selectedProxy = $this->server->proxyType(); - $this->redirect_enabled = data_get($this->server, 'proxy.redirect_enabled', true); - $this->redirect_url = data_get($this->server, 'proxy.redirect_url'); + $this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true); + $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); } - // public function proxyStatusUpdated() - // { - // $this->dispatch('refresh')->self(); - // } + public function getConfigurationFilePathProperty() + { + return $this->server->proxyPath().'docker-compose.yml'; + } public function changeProxy() { + $this->authorize('update', $this->server); $this->server->proxy = null; $this->server->save(); @@ -56,6 +60,7 @@ class Proxy extends Component public function selectProxy($proxy_type) { try { + $this->authorize('update', $this->server); $this->server->changeProxy($proxy_type, async: false); $this->selectedProxy = $this->server->proxy->type; @@ -68,6 +73,7 @@ class Proxy extends Component public function instantSave() { try { + $this->authorize('update', $this->server); $this->validate(); $this->server->settings->save(); $this->dispatch('success', 'Settings saved.'); @@ -79,7 +85,8 @@ class Proxy extends Component public function instantSaveRedirect() { try { - $this->server->proxy->redirect_enabled = $this->redirect_enabled; + $this->authorize('update', $this->server); + $this->server->proxy->redirect_enabled = $this->redirectEnabled; $this->server->save(); $this->server->setupDefaultRedirect(); $this->dispatch('success', 'Proxy configuration saved.'); @@ -91,8 +98,9 @@ class Proxy extends Component public function submit() { try { - SaveConfiguration::run($this->server, $this->proxy_settings); - $this->server->proxy->redirect_url = $this->redirect_url; + $this->authorize('update', $this->server); + SaveProxyConfiguration::run($this->server, $this->proxySettings); + $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->save(); $this->server->setupDefaultRedirect(); $this->dispatch('success', 'Proxy configuration saved.'); @@ -101,13 +109,15 @@ class Proxy extends Component } } - public function reset_proxy_configuration() + public function resetProxyConfiguration() { try { - $this->proxy_settings = CheckConfiguration::run($this->server, true); - SaveConfiguration::run($this->server, $this->proxy_settings); + $this->authorize('update', $this->server); + // Explicitly regenerate default configuration + $this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true); + SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->save(); - $this->dispatch('success', 'Proxy configuration saved.'); + $this->dispatch('success', 'Proxy configuration reset to default.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -116,7 +126,7 @@ class Proxy extends Component public function loadProxyConfiguration() { try { - $this->proxy_settings = CheckConfiguration::run($this->server); + $this->proxySettings = GetProxyConfiguration::run($this->server); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php index 392ad38fa..f377bbeb9 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php @@ -3,12 +3,17 @@ namespace App\Livewire\Server\Proxy; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class DynamicConfigurationNavbar extends Component { + use AuthorizesRequests; + public $server_id; + public Server $server; + public $fileName = ''; public $value = ''; @@ -17,18 +22,18 @@ class DynamicConfigurationNavbar extends Component public function delete(string $fileName) { - $server = Server::ownedByCurrentTeam()->whereId($this->server_id)->first(); - $proxy_path = $server->proxyPath(); - $proxy_type = $server->proxyType(); + $this->authorize('update', $this->server); + $proxy_path = $this->server->proxyPath(); + $proxy_type = $this->server->proxyType(); $file = str_replace('|', '.', $fileName); if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { $this->dispatch('error', 'Cannot delete Caddyfile.'); return; } - instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $server); + instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server); if ($proxy_type === 'CADDY') { - $server->reloadCaddy(); + $this->server->reloadCaddy(); } $this->dispatch('success', 'File deleted.'); $this->dispatch('loadDynamicConfigurations'); diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php index 2155f1e82..eb2db1cbb 100644 --- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php +++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php @@ -4,11 +4,14 @@ namespace App\Livewire\Server\Proxy; use App\Enums\ProxyTypes; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Symfony\Component\Yaml\Yaml; class NewDynamicConfiguration extends Component { + use AuthorizesRequests; + public string $fileName = ''; public string $value = ''; @@ -23,6 +26,7 @@ class NewDynamicConfiguration extends Component public function mount() { + $this->server = Server::ownedByCurrentTeam()->whereId($this->server_id)->first(); $this->parameters = get_route_parameters(); if ($this->fileName !== '') { $this->fileName = str_replace('|', '.', $this->fileName); @@ -32,6 +36,7 @@ class NewDynamicConfiguration extends Component public function addDynamicConfiguration() { try { + $this->authorize('update', $this->server); $this->validate([ 'fileName' => 'required', 'value' => 'required', @@ -39,9 +44,7 @@ class NewDynamicConfiguration extends Component if (data_get($this->parameters, 'server_uuid')) { $this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first(); } - if (! is_null($this->server_id)) { - $this->server = Server::ownedByCurrentTeam()->whereId($this->server_id)->first(); - } + if (is_null($this->server)) { return redirect()->route('server.index'); } diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index b7d17a61d..b4d151424 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -6,10 +6,14 @@ use App\Actions\Server\CheckUpdates; use App\Actions\Server\UpdatePackage; use App\Events\ServerPackageUpdated; use App\Models\Server; +use App\Notifications\Server\ServerPatchCheck; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Patches extends Component { + use AuthorizesRequests; + public array $parameters; public Server $server; @@ -35,11 +39,9 @@ class Patches extends Component public function mount() { - if (! auth()->user()->isAdmin()) { - abort(403); - } $this->parameters = get_route_parameters(); $this->server = Server::ownedByCurrentTeam()->whereUuid($this->parameters['server_uuid'])->firstOrFail(); + $this->authorize('viewSecurity', $this->server); } public function checkForUpdatesDispatch() @@ -67,8 +69,9 @@ class Patches extends Component public function updateAllPackages() { + $this->authorize('update', $this->server); if (! $this->packageManager || ! $this->osId) { - $this->dispatch('error', message: 'Run “Check for updates” first.'); + $this->dispatch('error', message: 'Run "Check for updates" first.'); return; } @@ -89,6 +92,7 @@ class Patches extends Component public function updatePackage($package) { try { + $this->authorize('update', $this->server); $activity = UpdatePackage::run(server: $this->server, packageManager: $this->packageManager, osId: $this->osId, package: $package); $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); } catch (\Exception $e) { @@ -96,6 +100,89 @@ class Patches extends Component } } + public function sendTestEmail() + { + if (! isDev()) { + $this->dispatch('error', message: 'Test email functionality is only available in development mode.'); + + return; + } + + try { + // Get current patch data or create test data if none exists + $testPatchData = $this->createTestPatchData(); + + // Send test notification + $this->server->team->notify(new ServerPatchCheck($this->server, $testPatchData)); + + $this->dispatch('success', 'Test email sent successfully! Check your email inbox.'); + } catch (\Exception $e) { + $this->dispatch('error', message: 'Failed to send test email: '.$e->getMessage()); + } + } + + private function createTestPatchData(): array + { + // If we have real patch data, use it + if (isset($this->updates) && is_array($this->updates) && count($this->updates) > 0) { + return [ + 'total_updates' => $this->totalUpdates, + 'updates' => $this->updates, + 'osId' => $this->osId, + 'package_manager' => $this->packageManager, + ]; + } + + // Otherwise create realistic test data + return [ + 'total_updates' => 8, + 'updates' => [ + [ + 'package' => 'docker-ce', + 'current_version' => '24.0.7-1', + 'new_version' => '25.0.1-1', + ], + [ + 'package' => 'nginx', + 'current_version' => '1.20.2-1', + 'new_version' => '1.22.1-1', + ], + [ + 'package' => 'kernel-generic', + 'current_version' => '5.15.0-89', + 'new_version' => '5.15.0-91', + ], + [ + 'package' => 'openssh-server', + 'current_version' => '8.9p1-3', + 'new_version' => '9.0p1-1', + ], + [ + 'package' => 'curl', + 'current_version' => '7.81.0-1', + 'new_version' => '7.85.0-1', + ], + [ + 'package' => 'git', + 'current_version' => '2.34.1-1', + 'new_version' => '2.39.1-1', + ], + [ + 'package' => 'python3', + 'current_version' => '3.10.6-1', + 'new_version' => '3.11.0-1', + ], + [ + 'package' => 'htop', + 'current_version' => '3.2.1-1', + 'new_version' => '3.2.2-1', + ], + ], + 'osId' => $this->osId ?? 'ubuntu', + 'package_manager' => $this->packageManager ?? 'apt', + ]; + } + public function render() { return view('livewire.server.security.patches'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index d53f10d74..473e0b60e 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -6,91 +6,130 @@ use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; use App\Events\ServerReachabilityChanged; use App\Models\Server; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; -use Livewire\Attributes\Validate; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public Server $server; - #[Validate(['required'])] public string $name; - #[Validate(['nullable'])] public ?string $description = null; - #[Validate(['required'])] public string $ip; - #[Validate(['required'])] public string $user; - #[Validate(['required'])] public string $port; - #[Validate(['nullable'])] public ?string $validationLogs = null; - #[Validate(['nullable', 'url'])] public ?string $wildcardDomain = null; - #[Validate(['required'])] public bool $isReachable; - #[Validate(['required'])] public bool $isUsable; - #[Validate(['required'])] public bool $isSwarmManager; - #[Validate(['required'])] public bool $isSwarmWorker; - #[Validate(['required'])] public bool $isBuildServer; #[Locked] public bool $isBuildServerLocked = false; - #[Validate(['required'])] public bool $isMetricsEnabled; - #[Validate(['required'])] public string $sentinelToken; - #[Validate(['nullable'])] public ?string $sentinelUpdatedAt = null; - #[Validate(['required', 'integer', 'min:1'])] public int $sentinelMetricsRefreshRateSeconds; - #[Validate(['required', 'integer', 'min:1'])] public int $sentinelMetricsHistoryDays; - #[Validate(['required', 'integer', 'min:10'])] public int $sentinelPushIntervalSeconds; - #[Validate(['nullable', 'url'])] public ?string $sentinelCustomUrl = null; - #[Validate(['required'])] public bool $isSentinelEnabled; - #[Validate(['required'])] public bool $isSentinelDebugEnabled; - #[Validate(['required'])] + public ?string $sentinelCustomDockerImage = null; + public string $serverTimezone; public function getListeners() { + $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; + return [ 'refreshServerShow' => 'refresh', + "echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted', ]; } + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'ip' => 'required', + 'user' => 'required', + 'port' => 'required', + 'validationLogs' => 'nullable', + 'wildcardDomain' => 'nullable|url', + 'isReachable' => 'required', + 'isUsable' => 'required', + 'isSwarmManager' => 'required', + 'isSwarmWorker' => 'required', + 'isBuildServer' => 'required', + 'isMetricsEnabled' => 'required', + 'sentinelToken' => 'required', + 'sentinelUpdatedAt' => 'nullable', + 'sentinelMetricsRefreshRateSeconds' => 'required|integer|min:1', + 'sentinelMetricsHistoryDays' => 'required|integer|min:1', + 'sentinelPushIntervalSeconds' => 'required|integer|min:10', + 'sentinelCustomUrl' => 'nullable|url', + 'isSentinelEnabled' => 'required', + 'isSentinelDebugEnabled' => 'required', + 'serverTimezone' => 'required', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'ip.required' => 'The IP Address field is required.', + 'user.required' => 'The User field is required.', + 'port.required' => 'The Port field is required.', + 'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.', + 'sentinelToken.required' => 'The Sentinel Token field is required.', + 'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.', + 'sentinelMetricsRefreshRateSeconds.integer' => 'The Metrics Refresh Rate must be an integer.', + 'sentinelMetricsRefreshRateSeconds.min' => 'The Metrics Refresh Rate must be at least 1 second.', + 'sentinelMetricsHistoryDays.required' => 'The Metrics History Days field is required.', + 'sentinelMetricsHistoryDays.integer' => 'The Metrics History Days must be an integer.', + 'sentinelMetricsHistoryDays.min' => 'The Metrics History Days must be at least 1 day.', + 'sentinelPushIntervalSeconds.required' => 'The Push Interval field is required.', + 'sentinelPushIntervalSeconds.integer' => 'The Push Interval must be an integer.', + 'sentinelPushIntervalSeconds.min' => 'The Push Interval must be at least 10 seconds.', + 'sentinelCustomUrl.url' => 'The Custom Sentinel URL must be a valid URL.', + 'serverTimezone.required' => 'The Server Timezone field is required.', + ] + ); + } + public function mount(string $server_uuid) { try { @@ -118,6 +157,7 @@ class Show extends Component if ($toModel) { $this->validate(); + $this->authorize('update', $this->server); if (Server::where('team_id', currentTeam()->id) ->where('ip', $this->ip) ->where('id', '!=', $this->server->id) @@ -186,9 +226,20 @@ class Show extends Component $this->syncData(); } + public function handleSentinelRestarted($event) + { + // Only refresh if the event is for this server + if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) { + $this->server->refresh(); + $this->syncData(); + $this->dispatch('success', 'Sentinel has been restarted successfully.'); + } + } + public function validateServer($install = true) { try { + $this->authorize('update', $this->server); $this->validationLogs = $this->server->validation_logs = null; $this->server->save(); $this->dispatch('init', $install); @@ -216,40 +267,86 @@ class Show extends Component public function restartSentinel() { - $this->server->restartSentinel(); - $this->dispatch('success', 'Sentinel restarted.'); + try { + $this->authorize('manageSentinel', $this->server); + $customImage = isDev() ? $this->sentinelCustomDockerImage : null; + $this->server->restartSentinel($customImage); + $this->dispatch('success', 'Restarting Sentinel.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } public function updatedIsSentinelDebugEnabled($value) { - $this->submit(); - $this->restartSentinel(); + try { + $this->submit(); + $this->restartSentinel(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function updatedIsMetricsEnabled($value) { - $this->submit(); - $this->restartSentinel(); + try { + $this->submit(); + $this->restartSentinel(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function updatedIsBuildServer($value) + { + try { + $this->authorize('update', $this->server); + if ($value === true && $this->isSentinelEnabled) { + $this->isSentinelEnabled = false; + $this->isMetricsEnabled = false; + $this->isSentinelDebugEnabled = false; + StopSentinel::dispatch($this->server); + $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); + } + $this->submit(); + // Dispatch event to refresh the navbar + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function updatedIsSentinelEnabled($value) { - if ($value === true) { - StartSentinel::run($this->server, true); - } else { - $this->isMetricsEnabled = false; - $this->isSentinelDebugEnabled = false; - StopSentinel::dispatch($this->server); - } - $this->submit(); + try { + $this->authorize('manageSentinel', $this->server); + if ($value === true) { + if ($this->isBuildServer) { + $this->isSentinelEnabled = false; + $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); + return; + } + $customImage = isDev() ? $this->sentinelCustomDockerImage : null; + StartSentinel::run($this->server, true, null, $customImage); + } else { + $this->isMetricsEnabled = false; + $this->isSentinelDebugEnabled = false; + StopSentinel::dispatch($this->server); + } + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function regenerateSentinelToken() { try { + $this->authorize('manageSentinel', $this->server); $this->server->settings->generateSentinelToken(); - $this->dispatch('success', 'Token regenerated & Sentinel restarted.'); + $this->dispatch('success', 'Token regenerated. Restarting Sentinel.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -257,7 +354,11 @@ class Show extends Component public function instantSave() { - $this->submit(); + try { + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function submit() diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 479fdef22..c75474e44 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -5,10 +5,13 @@ namespace App\Livewire\Server; use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; use App\Models\Server; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class ValidateAndInstall extends Component { + use AuthorizesRequests; + public Server $server; public int $number_of_tries = 0; @@ -62,6 +65,7 @@ class ValidateAndInstall extends Component public function validateConnection() { + $this->authorize('update', $this->server); ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); if (! $this->uptime) { $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error.'
'; diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 4425b414d..832123d5a 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -4,6 +4,7 @@ namespace App\Livewire\Settings; use App\Models\InstanceSettings; use App\Models\Server; +use App\Rules\ValidIpOrCidr; use Auth; use Hash; use Livewire\Attributes\Validate; @@ -31,7 +32,6 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_api_enabled; - #[Validate('nullable|string')] public ?string $allowed_ips = null; #[Validate('boolean')] @@ -40,6 +40,21 @@ class Advanced extends Component #[Validate('boolean')] public bool $disable_two_step_confirmation; + public function rules() + { + return [ + 'server' => 'required', + 'is_registration_enabled' => 'boolean', + 'do_not_track' => 'boolean', + 'is_dns_validation_enabled' => 'boolean', + 'custom_dns_servers' => 'nullable|string', + 'is_api_enabled' => 'boolean', + 'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr], + 'is_sponsorship_popup_enabled' => 'boolean', + 'disable_two_step_confirmation' => 'boolean', + ]; + } + public function mount() { if (! isInstanceAdmin()) { @@ -67,12 +82,76 @@ class Advanced extends Component return str($dns)->trim()->lower(); })->unique()->implode(','); + // Handle allowed IPs with subnet support and 0.0.0.0 special case $this->allowed_ips = str($this->allowed_ips)->replaceEnd(',', '')->trim(); - $this->allowed_ips = str($this->allowed_ips)->trim()->explode(',')->map(function ($ip) { - return str($ip)->trim(); - })->unique()->implode(','); + + // Check if user entered 0.0.0.0 or left field empty (both allow access from anywhere) + $allowsFromAnywhere = false; + if (empty($this->allowed_ips)) { + $allowsFromAnywhere = true; + } elseif ($this->allowed_ips === '0.0.0.0' || str_contains($this->allowed_ips, '0.0.0.0')) { + $allowsFromAnywhere = true; + } + + // Check if it's 0.0.0.0 (allow all) or empty + if ($this->allowed_ips === '0.0.0.0' || empty($this->allowed_ips)) { + // Keep as is - empty means no restriction, 0.0.0.0 means allow all + } else { + // Validate and clean up the entries + $invalidEntries = []; + $validEntries = str($this->allowed_ips)->trim()->explode(',')->map(function ($entry) use (&$invalidEntries) { + $entry = str($entry)->trim()->toString(); + + if (empty($entry)) { + return null; + } + + // Check if it's valid CIDR notation + if (str_contains($entry, '/')) { + [$ip, $mask] = explode('/', $entry); + if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= 32) { + return $entry; + } + $invalidEntries[] = $entry; + + return null; + } + + // Check if it's a valid IP address + if (filter_var($entry, FILTER_VALIDATE_IP)) { + return $entry; + } + + $invalidEntries[] = $entry; + + return null; + })->filter()->unique(); + + if (! empty($invalidEntries)) { + $this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries)); + + return; + } + + // Also check if we have no valid entries after filtering + if ($validEntries->isEmpty()) { + $this->dispatch('error', 'No valid IP addresses or subnets provided'); + + return; + } + + $this->allowed_ips = $validEntries->implode(','); + } $this->instantSave(); + + // Show security warning if allowing access from anywhere + if ($allowsFromAnywhere) { + $message = empty($this->allowed_ips) + ? 'Empty IP allowlist allows API access from anywhere.

This is not recommended for production environments!' + : 'Using 0.0.0.0 allows API access from anywhere.

This is not recommended for production environments!'; + $this->dispatch('warning', $message); + } } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index bce343224..13d690352 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -35,6 +35,12 @@ class Index extends Component #[Validate('required|string|timezone')] public string $instance_timezone; + public array $domainConflicts = []; + + public bool $showDomainConflictModal = false; + + public bool $forceSaveDomains = false; + public function render() { return view('livewire.settings.index'); @@ -81,6 +87,13 @@ class Index extends Component } } + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); + } + public function submit() { try { @@ -102,13 +115,24 @@ class Index extends Component $this->validate(); if ($this->settings->is_dns_validation_enabled && $this->fqdn) { - if (! validate_dns_entry($this->fqdn, $this->server)) { + if (! validateDNSEntry($this->fqdn, $this->server)) { $this->dispatch('error', "Validating DNS failed.

Make sure you have added the DNS records correctly.

{$this->fqdn}->{$this->server->ip}

Check this documentation for further help."); $error_show = true; } } if ($this->fqdn) { - check_domain_usage(domain: $this->fqdn); + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(domain: $this->fqdn); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } } $this->instantSave(isSave: false); diff --git a/app/Livewire/SettingsDropdown.php b/app/Livewire/SettingsDropdown.php new file mode 100644 index 000000000..7afa763df --- /dev/null +++ b/app/Livewire/SettingsDropdown.php @@ -0,0 +1,73 @@ +getUnreadChangelogCount(); + } + + public function getEntriesProperty() + { + $user = Auth::user(); + + return app(ChangelogService::class)->getEntriesForUser($user); + } + + public function getCurrentVersionProperty() + { + return 'v'.config('constants.coolify.version'); + } + + public function openWhatsNewModal() + { + $this->showWhatsNewModal = true; + } + + public function closeWhatsNewModal() + { + $this->showWhatsNewModal = false; + } + + public function markAsRead($identifier) + { + app(ChangelogService::class)->markAsReadForUser($identifier, Auth::user()); + } + + public function markAllAsRead() + { + app(ChangelogService::class)->markAllAsReadForUser(Auth::user()); + } + + public function manualFetchChangelog() + { + if (! isDev()) { + return; + } + + try { + PullChangelog::dispatch(); + $this->dispatch('success', 'Changelog fetch initiated! Check back in a few moments.'); + } catch (\Throwable $e) { + $this->dispatch('error', 'Failed to fetch changelog: '.$e->getMessage()); + } + } + + public function render() + { + return view('livewire.settings-dropdown', [ + 'entries' => $this->entries, + 'unreadCount' => $this->unreadCount, + 'currentVersion' => $this->currentVersion, + ]); + } +} diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index e88ac5f13..bee757a64 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -4,10 +4,13 @@ namespace App\Livewire\SharedVariables\Environment; use App\Models\Application; use App\Models\Project; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public Project $project; public Application $application; @@ -21,6 +24,8 @@ class Show extends Component public function saveKey($data) { try { + $this->authorize('update', $this->environment); + $found = $this->environment->environment_variables()->where('key', $data['key'])->first(); if ($found) { throw new \Exception('Variable already exists.'); diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index 0171283c4..712a9960b 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -3,10 +3,13 @@ namespace App\Livewire\SharedVariables\Project; use App\Models\Project; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Show extends Component { + use AuthorizesRequests; + public Project $project; protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh']; @@ -14,6 +17,8 @@ class Show extends Component public function saveKey($data) { try { + $this->authorize('update', $this->project); + $found = $this->project->environment_variables()->where('key', $data['key'])->first(); if ($found) { throw new \Exception('Variable already exists.'); diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index a76ccf58a..82473528c 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -3,10 +3,13 @@ namespace App\Livewire\SharedVariables\Team; use App\Models\Team; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public Team $team; protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh']; @@ -14,6 +17,8 @@ class Index extends Component public function saveKey($data) { try { + $this->authorize('update', $this->team); + $found = $this->team->environment_variables()->where('key', $data['key'])->first(); if ($found) { throw new \Exception('Variable already exists.'); diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index e73c9dc73..9ad5444b9 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -5,6 +5,7 @@ namespace App\Livewire\Source\Github; use App\Jobs\GithubAppPermissionJob; use App\Models\GithubApp; use App\Models\PrivateKey; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; @@ -13,7 +14,9 @@ use Livewire\Component; class Change extends Component { - public string $webhook_endpoint; + use AuthorizesRequests; + + public string $webhook_endpoint = ''; public ?string $ipv4 = null; @@ -69,6 +72,8 @@ class Change extends Component public function checkPermissions() { try { + $this->authorize('view', $this->github_app); + GithubAppPermissionJob::dispatchSync($this->github_app); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->dispatch('success', 'Github App permissions updated.'); @@ -155,7 +160,7 @@ class Change extends Component if (isCloud() && ! isDev()) { $this->webhook_endpoint = config('app.url'); } else { - $this->webhook_endpoint = $this->ipv4; + $this->webhook_endpoint = $this->ipv4 ?? ''; $this->is_system_wide = $this->github_app->is_system_wide; } } catch (\Throwable $e) { @@ -195,6 +200,8 @@ class Change extends Component public function updateGithubAppName() { try { + $this->authorize('update', $this->github_app); + $privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id); if (! $privateKey) { @@ -237,6 +244,8 @@ class Change extends Component public function submit() { try { + $this->authorize('update', $this->github_app); + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->validate([ 'github_app.name' => 'required|string', @@ -262,6 +271,8 @@ class Change extends Component public function createGithubAppManually() { + $this->authorize('update', $this->github_app); + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->app_id = '1234567890'; $this->github_app->installation_id = '1234567890'; @@ -272,6 +283,8 @@ class Change extends Component public function instantSave() { try { + $this->authorize('update', $this->github_app); + $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->save(); $this->dispatch('success', 'Github App updated.'); @@ -283,6 +296,8 @@ class Change extends Component public function delete() { try { + $this->authorize('delete', $this->github_app); + if ($this->github_app->applications->isNotEmpty()) { $this->dispatch('error', 'This source is being used by an application. Please delete all applications first.'); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index 136d3525e..f5d851b64 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -3,10 +3,13 @@ namespace App\Livewire\Source\Github; use App\Models\GithubApp; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Create extends Component { + use AuthorizesRequests; + public string $name; public ?string $organization = null; @@ -29,6 +32,8 @@ class Create extends Component public function createGitHubApp() { try { + $this->authorize('createAnyResource'); + $this->validate([ 'name' => 'required|string', 'organization' => 'nullable|string', diff --git a/app/Livewire/Source/Gitlab/Change.php b/app/Livewire/Source/Gitlab/Change.php deleted file mode 100644 index 34600bb7d..000000000 --- a/app/Livewire/Source/Gitlab/Change.php +++ /dev/null @@ -1,13 +0,0 @@ - 'required|min:3|max:255', - 'description' => 'nullable|min:3|max:255', - 'region' => 'required|max:255', - 'key' => 'required|max:255', - 'secret' => 'required|max:255', - 'bucket' => 'required|max:255', - 'endpoint' => 'required|url|max:255', - ]; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + 'region' => 'required|max:255', + 'key' => 'required|max:255', + 'secret' => 'required|max:255', + 'bucket' => 'required|max:255', + 'endpoint' => 'required|url|max:255', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'region.required' => 'The Region field is required.', + 'region.max' => 'The Region may not be greater than 255 characters.', + 'key.required' => 'The Access Key field is required.', + 'key.max' => 'The Access Key may not be greater than 255 characters.', + 'secret.required' => 'The Secret Key field is required.', + 'secret.max' => 'The Secret Key may not be greater than 255 characters.', + 'bucket.required' => 'The Bucket field is required.', + 'bucket.max' => 'The Bucket may not be greater than 255 characters.', + 'endpoint.required' => 'The Endpoint field is required.', + 'endpoint.url' => 'The Endpoint must be a valid URL.', + 'endpoint.max' => 'The Endpoint may not be greater than 255 characters.', + ] + ); + } protected $validationAttributes = [ 'name' => 'Name', @@ -70,6 +97,8 @@ class Create extends Component public function submit() { try { + $this->authorize('create', S3Storage::class); + $this->validate(); $this->storage = new S3Storage; $this->storage->name = $this->name; diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php index ad1627863..41541f6b9 100644 --- a/app/Livewire/Storage/Form.php +++ b/app/Livewire/Storage/Form.php @@ -3,22 +3,51 @@ namespace App\Livewire\Storage; use App\Models\S3Storage; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Form extends Component { + use AuthorizesRequests; + public S3Storage $storage; - protected $rules = [ - 'storage.is_usable' => 'nullable|boolean', - 'storage.name' => 'nullable|min:3|max:255', - 'storage.description' => 'nullable|min:3|max:255', - 'storage.region' => 'required|max:255', - 'storage.key' => 'required|max:255', - 'storage.secret' => 'required|max:255', - 'storage.bucket' => 'required|max:255', - 'storage.endpoint' => 'required|url|max:255', - ]; + protected function rules(): array + { + return [ + 'storage.is_usable' => 'nullable|boolean', + 'storage.name' => ValidationPatterns::nameRules(required: false), + 'storage.description' => ValidationPatterns::descriptionRules(), + 'storage.region' => 'required|max:255', + 'storage.key' => 'required|max:255', + 'storage.secret' => 'required|max:255', + 'storage.bucket' => 'required|max:255', + 'storage.endpoint' => 'required|url|max:255', + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'storage.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'storage.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + 'storage.region.required' => 'The Region field is required.', + 'storage.region.max' => 'The Region may not be greater than 255 characters.', + 'storage.key.required' => 'The Access Key field is required.', + 'storage.key.max' => 'The Access Key may not be greater than 255 characters.', + 'storage.secret.required' => 'The Secret Key field is required.', + 'storage.secret.max' => 'The Secret Key may not be greater than 255 characters.', + 'storage.bucket.required' => 'The Bucket field is required.', + 'storage.bucket.max' => 'The Bucket may not be greater than 255 characters.', + 'storage.endpoint.required' => 'The Endpoint field is required.', + 'storage.endpoint.url' => 'The Endpoint must be a valid URL.', + 'storage.endpoint.max' => 'The Endpoint may not be greater than 255 characters.', + ] + ); + } protected $validationAttributes = [ 'storage.is_usable' => 'Is Usable', @@ -34,6 +63,8 @@ class Form extends Component public function testConnection() { try { + $this->authorize('validateConnection', $this->storage); + $this->storage->testConnection(shouldSave: true); return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.'); @@ -57,8 +88,10 @@ class Form extends Component public function submit() { - $this->validate(); try { + $this->authorize('update', $this->storage); + + $this->validate(); $this->testConnection(); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Subscription/Index.php b/app/Livewire/Subscription/Index.php index 8a9cc456f..ac37cca05 100644 --- a/app/Livewire/Subscription/Index.php +++ b/app/Livewire/Subscription/Index.php @@ -75,7 +75,7 @@ class Index extends Component } } catch (\Exception $e) { // Log the error - logger()->error('Stripe API error: ' . $e->getMessage()); + logger()->error('Stripe API error: '.$e->getMessage()); // Set a flag to show an error message to the user $this->addError('stripe', 'Could not retrieve subscription information. Please try again later.'); } finally { diff --git a/app/Livewire/Team/Create.php b/app/Livewire/Team/Create.php index f805d6122..d3d27556c 100644 --- a/app/Livewire/Team/Create.php +++ b/app/Livewire/Team/Create.php @@ -3,17 +3,28 @@ namespace App\Livewire\Team; use App\Models\Team; -use Livewire\Attributes\Validate; +use App\Support\ValidationPatterns; use Livewire\Component; class Create extends Component { - #[Validate(['required', 'min:3', 'max:255'])] public string $name = ''; - #[Validate(['nullable', 'min:3', 'max:255'])] public ?string $description = null; + protected function rules(): array + { + return [ + 'name' => ValidationPatterns::nameRules(), + 'description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return ValidationPatterns::combinedMessages(); + } + public function submit() { try { diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 0972e7364..8b9b70e14 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -4,20 +4,39 @@ namespace App\Livewire\Team; use App\Models\Team; use App\Models\TeamInvitation; +use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public $invitations = []; public Team $team; - protected $rules = [ - 'team.name' => 'required|min:3|max:255', - 'team.description' => 'nullable|min:3|max:255', - ]; + protected function rules(): array + { + return [ + 'team.name' => ValidationPatterns::nameRules(), + 'team.description' => ValidationPatterns::descriptionRules(), + ]; + } + + protected function messages(): array + { + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'team.name.required' => 'The Name field is required.', + 'team.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().', + 'team.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.', + ] + ); + } protected $validationAttributes = [ 'team.name' => 'name', @@ -42,6 +61,7 @@ class Index extends Component { $this->validate(); try { + $this->authorize('update', $this->team); $this->team->save(); refreshSession(); $this->dispatch('success', 'Team updated.'); @@ -53,6 +73,7 @@ class Index extends Component public function delete() { $currentTeam = currentTeam(); + $this->authorize('delete', $currentTeam); $currentTeam->delete(); $currentTeam->members->each(function ($user) use ($currentTeam) { diff --git a/app/Livewire/Team/Invitations.php b/app/Livewire/Team/Invitations.php index 3af0e0e92..523f640b9 100644 --- a/app/Livewire/Team/Invitations.php +++ b/app/Livewire/Team/Invitations.php @@ -4,10 +4,13 @@ namespace App\Livewire\Team; use App\Models\TeamInvitation; use App\Models\User; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Invitations extends Component { + use AuthorizesRequests; + public $invitations; protected $listeners = ['refreshInvitations']; @@ -15,6 +18,8 @@ class Invitations extends Component public function deleteInvitation(int $invitation_id) { try { + $this->authorize('manageInvitations', currentTeam()); + $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); $user = User::whereEmail($invitation->email)->first(); if (filled($user)) { diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index fb0c51e54..0bac39db8 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -4,6 +4,7 @@ namespace App\Livewire\Team; use App\Models\TeamInvitation; use App\Models\User; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Hash; @@ -13,6 +14,8 @@ use Visus\Cuid2\Cuid2; class InviteLink extends Component { + use AuthorizesRequests; + public string $email; public string $role = 'member'; @@ -40,6 +43,7 @@ class InviteLink extends Component private function generateInviteLink(bool $sendEmail = false) { try { + $this->authorize('manageInvitations', currentTeam()); $this->validate(); if (auth()->user()->role() === 'admin' && $this->role === 'owner') { throw new \Exception('Admins cannot invite owners.'); diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 890d640a0..96c98c637 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -4,16 +4,21 @@ namespace App\Livewire\Team; use App\Enums\Role; use App\Models\User; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; use Livewire\Component; class Member extends Component { + use AuthorizesRequests; + public User $member; public function makeAdmin() { try { + $this->authorize('manageMembers', currentTeam()); + if (Role::from(auth()->user()->role())->lt(Role::ADMIN) || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); @@ -28,6 +33,8 @@ class Member extends Component public function makeOwner() { try { + $this->authorize('manageMembers', currentTeam()); + if (Role::from(auth()->user()->role())->lt(Role::OWNER) || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); @@ -42,6 +49,8 @@ class Member extends Component public function makeReadonly() { try { + $this->authorize('manageMembers', currentTeam()); + if (Role::from(auth()->user()->role())->lt(Role::ADMIN) || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); @@ -56,6 +65,8 @@ class Member extends Component public function remove() { try { + $this->authorize('manageMembers', currentTeam()); + if (Role::from(auth()->user()->role())->lt(Role::ADMIN) || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); diff --git a/app/Livewire/Team/Member/Index.php b/app/Livewire/Team/Member/Index.php index 00b745fe4..e057ba3f6 100644 --- a/app/Livewire/Team/Member/Index.php +++ b/app/Livewire/Team/Member/Index.php @@ -3,15 +3,19 @@ namespace App\Livewire\Team\Member; use App\Models\TeamInvitation; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Index extends Component { + use AuthorizesRequests; + public $invitations = []; public function mount() { - if (auth()->user()->isAdminFromSession()) { + // Only load invitations for users who can manage them + if (auth()->user()->can('manageInvitations', currentTeam())) { $this->invitations = TeamInvitation::whereTeamId(currentTeam()->id)->get(); } } diff --git a/app/Livewire/Terminal/Index.php b/app/Livewire/Terminal/Index.php index 10084a991..03dbc1d91 100644 --- a/app/Livewire/Terminal/Index.php +++ b/app/Livewire/Terminal/Index.php @@ -18,9 +18,6 @@ class Index extends Component public function mount() { - if (! auth()->user()->isAdmin()) { - abort(403); - } $this->servers = Server::isReachable()->get()->filter(function ($server) { return $server->isTerminalEnabled(); }); @@ -59,7 +56,7 @@ class Index extends Component return null; })->filter(); - }); + })->sortBy('name'); } public function updatedSelectedUuid() diff --git a/app/Models/Application.php b/app/Models/Application.php index 86eea1de8..094e5c82b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,7 +4,9 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; use App\Services\ConfigurationGenerator; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -109,9 +111,9 @@ use Visus\Cuid2\Cuid2; class Application extends BaseModel { - use HasConfiguration, HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; - private static $parserVersion = '4'; + private static $parserVersion = '5'; protected $guarded = []; @@ -122,66 +124,6 @@ class Application extends BaseModel 'http_basic_auth_password' => 'encrypted', ]; - public function customNetworkAliases(): Attribute - { - return Attribute::make( - set: function ($value) { - if (is_null($value) || $value === '') { - return null; - } - - // If it's already a JSON string, decode it - if (is_string($value) && $this->isJson($value)) { - $value = json_decode($value, true); - } - - // If it's a string but not JSON, treat it as a comma-separated list - if (is_string($value) && ! is_array($value)) { - $value = explode(',', $value); - } - - $value = collect($value) - ->map(function ($alias) { - if (is_string($alias)) { - return str_replace(' ', '-', trim($alias)); - } - - return null; - }) - ->filter() - ->unique() // Remove duplicate values - ->values() - ->toArray(); - - return empty($value) ? null : json_encode($value); - }, - get: function ($value) { - if (is_null($value)) { - return null; - } - - if (is_string($value) && $this->isJson($value)) { - return json_decode($value, true); - } - - return is_array($value) ? $value : []; - } - ); - } - - /** - * Check if a string is a valid JSON - */ - private function isJson($string) - { - if (! is_string($string)) { - return false; - } - json_decode($string); - - return json_last_error() === JSON_ERROR_NONE; - } - protected static function booted() { static::addGlobalScope('withRelations', function ($builder) { @@ -249,6 +191,66 @@ class Application extends BaseModel }); } + public function customNetworkAliases(): Attribute + { + return Attribute::make( + set: function ($value) { + if (is_null($value) || $value === '') { + return null; + } + + // If it's already a JSON string, decode it + if (is_string($value) && $this->isJson($value)) { + $value = json_decode($value, true); + } + + // If it's a string but not JSON, treat it as a comma-separated list + if (is_string($value) && ! is_array($value)) { + $value = explode(',', $value); + } + + $value = collect($value) + ->map(function ($alias) { + if (is_string($alias)) { + return str_replace(' ', '-', trim($alias)); + } + + return null; + }) + ->filter() + ->unique() // Remove duplicate values + ->values() + ->toArray(); + + return empty($value) ? null : json_encode($value); + }, + get: function ($value) { + if (is_null($value)) { + return null; + } + + if (is_string($value) && $this->isJson($value)) { + return json_decode($value, true); + } + + return is_array($value) ? $value : []; + } + ); + } + + /** + * Check if a string is a valid JSON + */ + private function isJson($string) + { + if (! is_string($string)) { + return false; + } + json_decode($string); + + return json_last_error() === JSON_ERROR_NONE; + } + public static function ownedByCurrentTeamAPI(int $teamId) { return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); @@ -727,7 +729,14 @@ class Application extends BaseModel { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables() @@ -737,14 +746,6 @@ class Application extends BaseModel ->where('key', 'not like', 'NIXPACKS_%'); } - public function build_environment_variables() - { - return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->where('is_preview', false) - ->where('is_build_time', true) - ->where('key', 'not like', 'NIXPACKS_%'); - } - public function nixpacks_environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') @@ -756,7 +757,14 @@ class Application extends BaseModel { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) - ->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC"); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables_preview() @@ -766,14 +774,6 @@ class Application extends BaseModel ->where('key', 'not like', 'NIXPACKS_%'); } - public function build_environment_variables_preview() - { - return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->where('is_preview', true) - ->where('is_build_time', true) - ->where('key', 'not like', 'NIXPACKS_%'); - } - public function nixpacks_environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') @@ -933,11 +933,11 @@ class Application extends BaseModel public function isConfigurationChanged(bool $save = false) { - $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels); + $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$this->settings->use_build_secrets); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { - $newConfigHash .= json_encode($this->environment_variables()->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { - $newConfigHash .= json_encode($this->environment_variables_preview->get('value')->sort()); + $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); @@ -979,15 +979,26 @@ class Application extends BaseModel public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false) { $baseDir = $this->generateBaseDir($deployment_uuid); + $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; if ($this->git_commit_sha !== 'HEAD') { - $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"; + // If shallow clone is enabled and we need a specific commit, + // we need to fetch that specific commit with depth=1 + 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"; + } 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"; + } } if ($this->settings->is_git_submodules_enabled) { + // Check if .gitmodules file exists before running submodule commands + $git_clone_command = "{$git_clone_command} && cd {$baseDir} && if [ -f .gitmodules ]; then"; if ($public) { - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && sed -i \"s#git@\(.*\):#https://\\1/#g\" {$baseDir}/.gitmodules || true"; + $git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$baseDir}/.gitmodules || true &&"; } - $git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive"; + // Add shallow submodules flag if shallow clone is enabled + $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"; } 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"; @@ -1116,10 +1127,28 @@ class Application extends BaseModel $branch = $this->git_branch; ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); $baseDir = $custom_base_dir ?? $this->generateBaseDir($deployment_uuid); + + // Escape shell arguments for safety to prevent command injection + $escapedBranch = escapeshellarg($branch); + $escapedBaseDir = escapeshellarg($baseDir); + $commands = collect([]); - $git_clone_command = "git clone -b \"{$this->git_branch}\""; + + // Check if shallow clone is enabled + $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; + $depthFlag = $isShallowCloneEnabled ? ' --depth=1' : ''; + + $submoduleFlags = ''; + if ($this->settings->is_git_submodules_enabled) { + $submoduleFlags = ' --recurse-submodules'; + if ($isShallowCloneEnabled) { + $submoduleFlags .= ' --shallow-submodules'; + } + } + + $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} -b {$escapedBranch}"; if ($only_checkout) { - $git_clone_command = "git clone --no-checkout -b \"{$this->git_branch}\""; + $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} --no-checkout -b {$escapedBranch}"; } if ($pull_request_id !== 0) { $pr_branch_name = "pr-{$pull_request_id}-coolify"; @@ -1133,7 +1162,8 @@ class Application extends BaseModel if ($this->source->getMorphClass() === \App\Models\GithubApp::class) { if ($this->source->is_public) { $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; - $git_clone_command = "{$git_clone_command} {$this->source->html_url}/{$customRepository} {$baseDir}"; + $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); + $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; if (! $only_checkout) { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true); } @@ -1145,11 +1175,15 @@ class Application extends BaseModel } else { $github_access_token = generateGithubInstallationToken($this->source); if ($exec_in_docker) { - $git_clone_command = "{$git_clone_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git {$baseDir}"; - $fullRepoUrl = "$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"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; + $fullRepoUrl = $repoUrl; } else { - $git_clone_command = "{$git_clone_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository} {$baseDir}"; - $fullRepoUrl = "$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}"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; + $fullRepoUrl = $repoUrl; } if (! $only_checkout) { $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false); @@ -1164,10 +1198,11 @@ class Application extends BaseModel $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name); + $escapedPrBranch = escapeshellarg($branch); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, "cd {$baseDir} && git fetch origin {$branch} && $git_checkout_command")); + $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")); } else { - $commands->push("cd {$baseDir} && git fetch origin {$branch} && $git_checkout_command"); + $commands->push("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"); } } @@ -1185,7 +1220,8 @@ class Application extends BaseModel throw new RuntimeException('Private key not found. Please add a private key to the application and try again.'); } $private_key = base64_encode($private_key); - $git_clone_command_base = "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_clone_command} {$customRepository} {$baseDir}"; + $escapedCustomRepository = escapeshellarg($customRepository); + $git_clone_command_base = "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_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { @@ -1353,7 +1389,7 @@ class Application extends BaseModel public function parse(int $pull_request_id = 0, ?int $preview_id = null) { if ((int) $this->compose_parsing_version >= 3) { - return newParser($this, $pull_request_id, $preview_id); + return applicationParser($this, $pull_request_id, $preview_id); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile(resource: $this, isNew: false, pull_request_id: $pull_request_id, preview_id: $preview_id); } else { @@ -1442,7 +1478,21 @@ class Application extends BaseModel $parsedServices = $this->parse(); if ($this->docker_compose_domains) { $json = collect(json_decode($this->docker_compose_domains)); - $names = collect(data_get($parsedServices, 'services'))->keys()->toArray(); + foreach ($json as $key => $value) { + if (str($key)->contains('-')) { + $key = str($key)->replace('-', '_')->replace('.', '_'); + } + $json->put((string) $key, $value); + } + $services = collect(data_get($parsedServices, 'services', [])); + foreach ($services as $name => $service) { + if (str($name)->contains('-')) { + $replacedName = str($name)->replace('-', '_')->replace('.', '_'); + $services->put((string) $replacedName, $service); + $services->forget((string) $name); + } + } + $names = collect($services)->keys()->toArray(); $jsonNames = $json->keys()->toArray(); $diff = array_diff($jsonNames, $names); $json = $json->filter(function ($value, $key) use ($diff) { @@ -1521,7 +1571,19 @@ class Application extends BaseModel if (is_null($this->watch_paths)) { return false; } - $watch_paths = collect(explode("\n", $this->watch_paths)); + $watch_paths = collect(explode("\n", $this->watch_paths)) + ->map(function (string $path): string { + return trim($path); + }) + ->filter(function (string $path): bool { + return strlen($path) > 0; + }); + + // If no valid patterns after filtering, don't trigger + if ($watch_paths->isEmpty()) { + return false; + } + $matches = $modified_files->filter(function ($file) use ($watch_paths) { return $watch_paths->contains(function ($glob) use ($file) { return fnmatch($glob, $file); diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 2a9bea67a..8df6877ab 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -85,6 +85,47 @@ class ApplicationDeploymentQueue extends Model return str($this->commit_message)->value(); } + private function redactSensitiveInfo($text) + { + $text = remove_iip($text); + + $app = $this->application; + if (! $app) { + return $text; + } + + $lockedVars = collect([]); + + if ($app->environment_variables) { + $lockedVars = $lockedVars->merge( + $app->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + if ($this->pull_request_id !== 0 && $app->environment_variables_preview) { + $lockedVars = $lockedVars->merge( + $app->environment_variables_preview + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace( + '/'.$escapedValue.'/', + REDACTED, + $text + ); + } + + return $text; + } + public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false) { if ($type === 'error') { @@ -96,7 +137,7 @@ class ApplicationDeploymentQueue extends Model } $newLogEntry = [ 'command' => null, - 'output' => remove_iip($message), + 'output' => $this->redactSensitiveInfo($message), 'type' => $type, 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index f45f9da40..721b22216 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -74,7 +74,7 @@ class ApplicationPreview extends BaseModel public function generate_preview_fqdn() { - if (is_null($this->fqdn) && $this->application->fqdn) { + if ($this->application->fqdn) { if (str($this->application->fqdn)->contains(',')) { $url = Url::fromString(str($this->application->fqdn)->explode(',')[0]); $preview_fqdn = getFqdnWithoutPort(str($this->application->fqdn)->explode(',')[0]); diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index c7624fdaa..4b03c69e1 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -13,8 +13,10 @@ class ApplicationSetting extends Model 'is_force_https_enabled' => 'boolean', 'is_debug_enabled' => 'boolean', 'is_preview_deployments_enabled' => 'boolean', + 'is_pr_deployments_public_enabled' => 'boolean', 'is_git_submodules_enabled' => 'boolean', 'is_git_lfs_enabled' => 'boolean', + 'is_git_shallow_clone_enabled' => 'boolean', ]; protected $guarded = []; diff --git a/app/Models/Environment.php b/app/Models/Environment.php index b8f1090d8..437be7d87 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -2,7 +2,7 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Casts\Attribute; +use App\Traits\HasSafeStringAttribute; use OpenApi\Attributes as OA; #[OA\Schema( @@ -19,6 +19,8 @@ use OpenApi\Attributes as OA; )] class Environment extends BaseModel { + use HasSafeStringAttribute; + protected $guarded = []; protected static function booted() @@ -119,10 +121,8 @@ class Environment extends BaseModel return $this->hasMany(Service::class); } - protected function name(): Attribute + protected function customizeName($value) { - return Attribute::make( - set: fn (string $value) => str($value)->lower()->trim()->replace('/', '-')->toString(), - ); + return str($value)->lower()->trim()->replace('/', '-')->toString(); } } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index b8bde5c84..80399a16b 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -14,10 +14,11 @@ use OpenApi\Attributes as OA; 'uuid' => ['type' => 'string'], 'resourceable_type' => ['type' => 'string'], 'resourceable_id' => ['type' => 'integer'], - 'is_build_time' => ['type' => 'boolean'], 'is_literal' => ['type' => 'boolean'], 'is_multiline' => ['type' => 'boolean'], 'is_preview' => ['type' => 'boolean'], + 'is_runtime' => ['type' => 'boolean'], + 'is_buildtime' => ['type' => 'boolean'], 'is_shared' => ['type' => 'boolean'], 'is_shown_once' => ['type' => 'boolean'], 'key' => ['type' => 'string'], @@ -35,15 +36,16 @@ class EnvironmentVariable extends BaseModel protected $casts = [ 'key' => 'string', 'value' => 'encrypted', - 'is_build_time' => 'boolean', 'is_multiline' => 'boolean', 'is_preview' => 'boolean', + 'is_runtime' => 'boolean', + 'is_buildtime' => 'boolean', 'version' => 'string', 'resourceable_type' => 'string', 'resourceable_id' => 'integer', ]; - protected $appends = ['real_value', 'is_shared', 'is_really_required']; + protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify']; protected static function booted() { @@ -61,8 +63,8 @@ class EnvironmentVariable extends BaseModel ModelsEnvironmentVariable::create([ 'key' => $environment_variable->key, 'value' => $environment_variable->value, - 'is_build_time' => $environment_variable->is_build_time, 'is_multiline' => $environment_variable->is_multiline ?? false, + 'is_literal' => $environment_variable->is_literal ?? false, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, @@ -137,6 +139,32 @@ class EnvironmentVariable extends BaseModel ); } + protected function isNixpacks(): Attribute + { + return Attribute::make( + get: function () { + if (str($this->key)->startsWith('NIXPACKS_')) { + return true; + } + + return false; + } + ); + } + + protected function isCoolify(): Attribute + { + return Attribute::make( + get: function () { + if (str($this->key)->startsWith('SERVICE_')) { + return true; + } + + return false; + } + ); + } + protected function isShared(): Attribute { return Attribute::make( diff --git a/app/Models/Kubernetes.php b/app/Models/Kubernetes.php deleted file mode 100644 index 174cb5bc8..000000000 --- a/app/Models/Kubernetes.php +++ /dev/null @@ -1,5 +0,0 @@ -is_directory) { $commands->push("mkdir -p $this->fs_path > /dev/null 2>&1 || true"); + $commands->push("mkdir -p $workdir > /dev/null 2>&1 || true"); $commands->push("cd $workdir"); } if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) { diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index b5dfd9663..00dc15fea 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -24,11 +24,9 @@ class LocalPersistentVolume extends Model return $this->morphTo('resource'); } - protected function name(): Attribute + protected function customizeName($value) { - return Attribute::make( - set: fn (string $value) => str($value)->trim()->value, - ); + return str($value)->trim()->value; } protected function mountPath(): Attribute diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index dbed7b439..c210f3c5b 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -2,7 +2,9 @@ namespace App\Models; +use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; @@ -27,7 +29,7 @@ use phpseclib3\Crypt\PublicKeyLoader; )] class PrivateKey extends BaseModel { - use WithRateLimiting; + use HasSafeStringAttribute, WithRateLimiting; protected $fillable = [ 'name', @@ -98,11 +100,18 @@ class PrivateKey extends BaseModel public static function createAndStore(array $data) { - $privateKey = new self($data); - $privateKey->save(); - $privateKey->storeInFileSystem(); + return DB::transaction(function () use ($data) { + $privateKey = new self($data); + $privateKey->save(); - return $privateKey; + try { + $privateKey->storeInFileSystem(); + } catch (\Exception $e) { + throw new \Exception('Failed to store SSH key: '.$e->getMessage()); + } + + return $privateKey; + }); } public static function generateNewKeyPair($type = 'rsa') @@ -150,15 +159,64 @@ class PrivateKey extends BaseModel public function storeInFileSystem() { $filename = "ssh_key@{$this->uuid}"; - Storage::disk('ssh-keys')->put($filename, $this->private_key); + $disk = Storage::disk('ssh-keys'); - return "/var/www/html/storage/app/ssh/keys/{$filename}"; + // Ensure the storage directory exists and is writable + $this->ensureStorageDirectoryExists(); + + // Attempt to store the private key + $success = $disk->put($filename, $this->private_key); + + if (! $success) { + throw new \Exception("Failed to write SSH key to filesystem. Check disk space and permissions for: {$this->getKeyLocation()}"); + } + + // Verify the file was actually created and has content + if (! $disk->exists($filename)) { + throw new \Exception("SSH key file was not created: {$this->getKeyLocation()}"); + } + + $storedContent = $disk->get($filename); + if (empty($storedContent) || $storedContent !== $this->private_key) { + $disk->delete($filename); // Clean up the bad file + throw new \Exception("SSH key file content verification failed: {$this->getKeyLocation()}"); + } + + return $this->getKeyLocation(); } public static function deleteFromStorage(self $privateKey) { $filename = "ssh_key@{$privateKey->uuid}"; - Storage::disk('ssh-keys')->delete($filename); + $disk = Storage::disk('ssh-keys'); + + if ($disk->exists($filename)) { + $disk->delete($filename); + } + } + + protected function ensureStorageDirectoryExists() + { + $disk = Storage::disk('ssh-keys'); + $directoryPath = ''; + + if (! $disk->exists($directoryPath)) { + $success = $disk->makeDirectory($directoryPath); + if (! $success) { + throw new \Exception('Failed to create SSH keys storage directory'); + } + } + + // Check if directory is writable by attempting a test file + $testFilename = '.test_write_'.uniqid(); + $testSuccess = $disk->put($testFilename, 'test'); + + if (! $testSuccess) { + throw new \Exception('SSH keys storage directory is not writable'); + } + + // Clean up test file + $disk->delete($testFilename); } public function getKeyLocation() @@ -168,10 +226,17 @@ class PrivateKey extends BaseModel public function updatePrivateKey(array $data) { - $this->update($data); - $this->storeInFileSystem(); + return DB::transaction(function () use ($data) { + $this->update($data); - return $this; + try { + $this->storeInFileSystem(); + } catch (\Exception $e) { + throw new \Exception('Failed to update SSH key: '.$e->getMessage()); + } + + return $this; + }); } public function servers() diff --git a/app/Models/Project.php b/app/Models/Project.php index 2e4d45859..1c46042e3 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\HasSafeStringAttribute; use OpenApi\Attributes as OA; use Visus\Cuid2\Cuid2; @@ -23,6 +24,8 @@ use Visus\Cuid2\Cuid2; )] class Project extends BaseModel { + use HasSafeStringAttribute; + protected $guarded = []; public static function ownedByCurrentTeam() diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index e9d674650..de27bbca6 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -2,13 +2,14 @@ namespace App\Models; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\Storage; class S3Storage extends BaseModel { - use HasFactory; + use HasFactory, HasSafeStringAttribute; protected $guarded = []; diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index 264a04d1f..06903ffb6 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -2,11 +2,14 @@ namespace App\Models; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; class ScheduledTask extends BaseModel { + use HasSafeStringAttribute; + protected $guarded = []; public function service() diff --git a/app/Models/Server.php b/app/Models/Server.php index 41ecdafb8..829a4b5aa 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -13,6 +13,8 @@ use App\Jobs\RegenerateSslCertJob; use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -54,7 +56,7 @@ use Visus\Cuid2\Cuid2; class Server extends BaseModel { - use HasFactory, SchemalessAttributesTrait, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes; public static $batch_counter = 0; @@ -164,6 +166,8 @@ class Server extends BaseModel protected $guarded = []; + use HasSafeStringAttribute; + public function type() { return 'server'; @@ -1256,13 +1260,13 @@ $schema://$host { return str($this->ip)->contains(':'); } - public function restartSentinel(bool $async = true) + public function restartSentinel(?string $customImage = null, bool $async = true) { try { if ($async) { - StartSentinel::dispatch($this, true); + StartSentinel::dispatch($this, true, null, $customImage); } else { - StartSentinel::run($this, true); + StartSentinel::run($this, true, null, $customImage); } } catch (\Throwable $e) { return handleError($e); diff --git a/app/Models/Service.php b/app/Models/Service.php index da6c34fbb..d42d471c6 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Enums\ProcessStatus; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -40,9 +42,9 @@ use Visus\Cuid2\Cuid2; )] class Service extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; - private static $parserVersion = '4'; + private static $parserVersion = '5'; protected $guarded = []; @@ -255,6 +257,19 @@ class Service extends BaseModel continue; } switch ($image) { + case $image->contains('drizzle-team/gateway'): + $data = collect([]); + $masterpass = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_DRIZZLE')->first(); + $data = $data->merge([ + 'Master Password' => [ + 'key' => data_get($masterpass, 'key'), + 'value' => data_get($masterpass, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + $fields->put('Drizzle', $data->toArray()); + break; case $image->contains('castopod'): $data = collect([]); $disable_https = $this->environment_variables()->where('key', 'CP_DISABLE_HTTPS')->first(); @@ -1099,7 +1114,6 @@ class Service extends BaseModel $this->environment_variables()->create([ 'key' => $key, 'value' => $value, - 'is_build_time' => false, 'resourceable_id' => $this->id, 'resourceable_type' => $this->getMorphClass(), 'is_preview' => false, @@ -1216,14 +1230,14 @@ class Service extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); - } - - public function environment_variables_preview() - { - return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->where('is_preview', true) - ->orderByRaw("LOWER(key) LIKE LOWER('SERVICE%') DESC, LOWER(key) ASC"); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function workdir() @@ -1277,7 +1291,7 @@ class Service extends BaseModel public function parse(bool $isNew = false): Collection { if ((int) $this->compose_parsing_version >= 3) { - return newParser($this); + return serviceParser($this); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile($this, $isNew); } else { diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index aab8b8735..7956f006a 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -12,4 +12,19 @@ class SharedEnvironmentVariable extends Model 'key' => 'string', 'value' => 'encrypted', ]; + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function project() + { + return $this->belongsTo(Project::class); + } + + public function environment() + { + return $this->belongsTo(Environment::class); + } } diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index fcd81cdc9..146ee0a2d 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -27,7 +29,6 @@ class StandaloneClickhouse extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -43,6 +44,11 @@ class StandaloneClickhouse extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -266,7 +272,14 @@ class StandaloneClickhouse extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables() diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index 9db6a2d29..aeb99d34a 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -2,8 +2,12 @@ namespace App\Models; +use App\Traits\HasSafeStringAttribute; + class StandaloneDocker extends BaseModel { + use HasSafeStringAttribute; + protected $guarded = []; protected static function boot() diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index fdf69b834..90e7304f1 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -27,7 +29,6 @@ class StandaloneDragonfly extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -43,6 +44,11 @@ class StandaloneDragonfly extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -341,6 +347,13 @@ class StandaloneDragonfly extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index d52023920..ad0cabf7e 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -27,7 +29,6 @@ class StandaloneKeydb extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -43,6 +44,11 @@ class StandaloneKeydb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -341,6 +347,13 @@ class StandaloneKeydb extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 5a8869b41..3d9e38147 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -9,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -28,7 +30,6 @@ class StandaloneMariadb extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -44,6 +45,11 @@ class StandaloneMariadb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -262,7 +268,14 @@ class StandaloneMariadb extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function runtime_environment_variables() diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 88833eebe..7cccd332a 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -23,7 +25,6 @@ class StandaloneMongodb extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); LocalPersistentVolume::create([ 'name' => 'mongodb-db-'.$database->uuid, @@ -31,7 +32,6 @@ class StandaloneMongodb extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -47,6 +47,11 @@ class StandaloneMongodb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -364,6 +369,13 @@ class StandaloneMongodb extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index dedc35f91..80269972f 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -28,7 +30,6 @@ class StandaloneMysql extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -44,6 +45,11 @@ class StandaloneMysql extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -345,6 +351,13 @@ class StandaloneMysql extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 689134a32..acde7a20c 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -28,7 +30,6 @@ class StandalonePostgresql extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -44,6 +45,11 @@ class StandalonePostgresql extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + public function workdir() { return database_configuration_dir()."/{$this->uuid}"; @@ -296,7 +302,14 @@ class StandalonePostgresql extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } public function isBackupSolutionAvailable() @@ -320,7 +333,10 @@ class StandalonePostgresql extends BaseModel } $metrics = json_decode($metrics, true); $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; + return [ + (int) $metric['time'], + (float) ($metric['percent'] ?? 0.0), + ]; }); return $parsedCollection->toArray(); diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 7f6f2ad72..001ebe36a 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use HasFactory, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -23,7 +25,6 @@ class StandaloneRedis extends BaseModel 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), - 'is_readonly' => true, ]); }); static::forceDeleting(function ($database) { @@ -45,6 +46,11 @@ class StandaloneRedis extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( @@ -388,6 +394,13 @@ class StandaloneRedis extends BaseModel public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') - ->orderBy('key', 'asc'); + ->orderByRaw(" + CASE + WHEN LOWER(key) LIKE 'service_%' THEN 1 + WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + ELSE 3 + END, + LOWER(key) ASC + "); } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index a64c994a3..3594d1072 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -2,18 +2,17 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Casts\Attribute; +use App\Traits\HasSafeStringAttribute; class Tag extends BaseModel { + use HasSafeStringAttribute; + protected $guarded = []; - public function name(): Attribute + protected function customizeName($value) { - return Attribute::make( - get: fn ($value) => strtolower($value), - set: fn ($value) => strtolower($value) - ); + return strtolower($value); } public static function ownedByCurrentTeam() diff --git a/app/Models/Team.php b/app/Models/Team.php index 42b88f9e7..81638e31c 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -8,6 +8,7 @@ use App\Notifications\Channels\SendsEmail; use App\Notifications\Channels\SendsPushover; use App\Notifications\Channels\SendsSlack; use App\Traits\HasNotificationSettings; +use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; @@ -36,7 +37,7 @@ use OpenApi\Attributes as OA; class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, SendsSlack { - use HasNotificationSettings, Notifiable; + use HasNotificationSettings, HasSafeStringAttribute, Notifiable; protected $guarded = []; diff --git a/app/Models/User.php b/app/Models/User.php index 6cd1b66db..9ab9fefe9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -53,8 +53,25 @@ class User extends Authenticatable implements SendsEmail 'email_verified_at' => 'datetime', 'force_password_reset' => 'boolean', 'show_boarding' => 'boolean', + 'email_change_code_expires_at' => 'datetime', ]; + /** + * Set the email attribute to lowercase. + */ + public function setEmailAttribute($value) + { + $this->attributes['email'] = strtolower($value); + } + + /** + * Set the pending_email attribute to lowercase. + */ + public function setPendingEmailAttribute($value) + { + $this->attributes['pending_email'] = $value ? strtolower($value) : null; + } + protected static function boot() { parent::boot(); @@ -203,6 +220,16 @@ class User extends Authenticatable implements SendsEmail return $this->belongsToMany(Team::class)->withPivot('role'); } + public function changelogReads() + { + return $this->hasMany(UserChangelogRead::class); + } + + public function getUnreadChangelogCount(): int + { + return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this); + } + public function getRecipients(): array { return [$this->email]; @@ -310,4 +337,77 @@ class User extends Authenticatable implements SendsEmail return data_get($user, 'pivot.role'); } + + public function requestEmailChange(string $newEmail): void + { + // Generate 6-digit code + $code = sprintf('%06d', mt_rand(0, 999999)); + + // Set expiration using config value + $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); + $expiresAt = Carbon::now()->addMinutes($expiryMinutes); + + $this->update([ + 'pending_email' => $newEmail, + 'email_change_code' => $code, + 'email_change_code_expires_at' => $expiresAt, + ]); + + // Send verification email to new address + $this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt)); + } + + public function isEmailChangeCodeValid(string $code): bool + { + return $this->email_change_code === $code + && $this->email_change_code_expires_at + && Carbon::now()->lessThan($this->email_change_code_expires_at); + } + + public function confirmEmailChange(string $code): bool + { + if (! $this->isEmailChangeCodeValid($code)) { + return false; + } + + $oldEmail = $this->email; + $newEmail = $this->pending_email; + + // Update email and clear change request fields + $this->update([ + 'email' => $newEmail, + 'pending_email' => null, + 'email_change_code' => null, + 'email_change_code_expires_at' => null, + ]); + + // For cloud users, dispatch job to update Stripe customer email asynchronously + if (isCloud() && $this->currentTeam()->subscription) { + dispatch(new \App\Jobs\UpdateStripeCustomerEmailJob( + $this->currentTeam(), + $this->id, + $newEmail, + $oldEmail + )); + } + + return true; + } + + public function clearEmailChangeRequest(): void + { + $this->update([ + 'pending_email' => null, + 'email_change_code' => null, + 'email_change_code_expires_at' => null, + ]); + } + + public function hasEmailChangeRequest(): bool + { + return ! is_null($this->pending_email) + && ! is_null($this->email_change_code) + && $this->email_change_code_expires_at + && Carbon::now()->lessThan($this->email_change_code_expires_at); + } } diff --git a/app/Models/UserChangelogRead.php b/app/Models/UserChangelogRead.php new file mode 100644 index 000000000..8c29ece14 --- /dev/null +++ b/app/Models/UserChangelogRead.php @@ -0,0 +1,48 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public static function markAsRead(int $userId, string $identifier): void + { + self::firstOrCreate([ + 'user_id' => $userId, + 'release_tag' => $identifier, + ], [ + 'read_at' => now(), + ]); + } + + public static function isReadByUser(int $userId, string $identifier): bool + { + return self::where('user_id', $userId) + ->where('release_tag', $identifier) + ->exists(); + } + + public static function getReadIdentifiersForUser(int $userId): array + { + return self::where('user_id', $userId) + ->pluck('release_tag') + ->toArray(); + } +} diff --git a/app/Models/Webhook.php b/app/Models/Webhook.php deleted file mode 100644 index 8e2b62955..000000000 --- a/app/Models/Webhook.php +++ /dev/null @@ -1,15 +0,0 @@ - 'string', - 'payload' => 'encrypted', - ]; -} diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 8a9a95107..245bd85f0 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -2,6 +2,9 @@ namespace App\Notifications\Channels; +use App\Exceptions\NonReportableException; +use App\Models\Team; +use Exception; use Illuminate\Notifications\Notification; use Resend; @@ -11,60 +14,100 @@ class EmailChannel public function send(SendsEmail $notifiable, Notification $notification): void { - $useInstanceEmailSettings = $notifiable->emailNotificationSettings->use_instance_email_settings; - $isTransactionalEmail = data_get($notification, 'isTransactionalEmail', false); - $customEmails = data_get($notification, 'emails', null); - if ($useInstanceEmailSettings || $isTransactionalEmail) { - $settings = instanceSettings(); - } else { - $settings = $notifiable->emailNotificationSettings; - } - $isResendEnabled = $settings->resend_enabled; - $isSmtpEnabled = $settings->smtp_enabled; - if ($customEmails) { - $recipients = [$customEmails]; - } else { - $recipients = $notifiable->getRecipients(); - } - $mailMessage = $notification->toMail($notifiable); + try { + // Get team and validate membership before proceeding + $team = data_get($notifiable, 'id'); + $members = Team::find($team)->members; - if ($isResendEnabled) { - $resend = Resend::client($settings->resend_api_key); - $from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>"; - $resend->emails->send([ - 'from' => $from, - 'to' => $recipients, - 'subject' => $mailMessage->subject, - 'html' => (string) $mailMessage->render(), - ]); - } elseif ($isSmtpEnabled) { - $encryption = match (strtolower($settings->smtp_encryption)) { - 'starttls' => null, - 'tls' => 'tls', - 'none' => null, - default => null, - }; + $useInstanceEmailSettings = $notifiable->emailNotificationSettings->use_instance_email_settings; + $isTransactionalEmail = data_get($notification, 'isTransactionalEmail', false); + $customEmails = data_get($notification, 'emails', null); - $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( - $settings->smtp_host, - $settings->smtp_port, - $encryption - ); - $transport->setUsername($settings->smtp_username ?? ''); - $transport->setPassword($settings->smtp_password ?? ''); + if ($useInstanceEmailSettings || $isTransactionalEmail) { + $settings = instanceSettings(); + } else { + $settings = $notifiable->emailNotificationSettings; + } - $mailer = new \Symfony\Component\Mailer\Mailer($transport); + $isResendEnabled = $settings->resend_enabled; + $isSmtpEnabled = $settings->smtp_enabled; - $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; - $fromName = $settings->smtp_from_name ?? 'System'; - $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); - $email = (new \Symfony\Component\Mime\Email) - ->from($from) - ->to(...$recipients) - ->subject($mailMessage->subject) - ->html((string) $mailMessage->render()); + if ($customEmails) { + $recipients = [$customEmails]; + } else { + $recipients = $notifiable->getRecipients(); + } - $mailer->send($email); + // Validate team membership for all recipients + if (count($recipients) === 0) { + throw new Exception('No email recipients found'); + } + + foreach ($recipients as $recipient) { + // Check if the recipient is part of the team + if (! $members->contains('email', $recipient)) { + $emailSettings = $notifiable->emailNotificationSettings; + data_set($emailSettings, 'smtp_password', '********'); + data_set($emailSettings, 'resend_api_key', '********'); + send_internal_notification(sprintf( + "Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s", + $recipient, + $team, + get_class($notification), + get_class($notifiable), + json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + )); + throw new Exception('Recipient is not part of the team'); + } + } + + $mailMessage = $notification->toMail($notifiable); + + if ($isResendEnabled) { + $resend = Resend::client($settings->resend_api_key); + $from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>"; + $resend->emails->send([ + 'from' => $from, + 'to' => $recipients, + 'subject' => $mailMessage->subject, + 'html' => (string) $mailMessage->render(), + ]); + } elseif ($isSmtpEnabled) { + $encryption = match (strtolower($settings->smtp_encryption)) { + 'starttls' => null, + 'tls' => 'tls', + 'none' => null, + default => null, + }; + + $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( + $settings->smtp_host, + $settings->smtp_port, + $encryption + ); + $transport->setUsername($settings->smtp_username ?? ''); + $transport->setPassword($settings->smtp_password ?? ''); + + $mailer = new \Symfony\Component\Mailer\Mailer($transport); + + $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; + $fromName = $settings->smtp_from_name ?? 'System'; + $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); + $email = (new \Symfony\Component\Mime\Email) + ->from($from) + ->to(...$recipients) + ->subject($mailMessage->subject) + ->html((string) $mailMessage->render()); + + $mailer->send($email); + } + } catch (\Throwable $e) { + // Check if this is a Resend domain verification error on cloud instances + if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { + // Throw as NonReportableException so it won't go to Sentry + throw NonReportableException::fromException($e); + } + throw $e; } } } diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 114d1f6ed..8ab74a60b 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -16,7 +16,12 @@ class TransactionalEmailChannel if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) { return; } - $email = $notifiable->email; + + // Check if notification has a custom recipient (for email changes) + $email = property_exists($notification, 'newEmail') && $notification->newEmail + ? $notification->newEmail + : $notifiable->email; + if (! $email) { return; } diff --git a/app/Notifications/TransactionalEmails/EmailChangeVerification.php b/app/Notifications/TransactionalEmails/EmailChangeVerification.php new file mode 100644 index 000000000..ea8462366 --- /dev/null +++ b/app/Notifications/TransactionalEmails/EmailChangeVerification.php @@ -0,0 +1,43 @@ +onQueue('high'); + } + + public function toMail(): MailMessage + { + // Use the configured expiry minutes value + $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); + + $mail = new MailMessage; + $mail->subject('Coolify: Verify Your New Email Address'); + $mail->view('emails.email-change-verification', [ + 'newEmail' => $this->newEmail, + 'verificationCode' => $this->verificationCode, + 'expiryMinutes' => $expiryMinutes, + ]); + + return $mail; + } +} diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php new file mode 100644 index 000000000..761227118 --- /dev/null +++ b/app/Policies/ApiTokenPolicy.php @@ -0,0 +1,109 @@ +id === $token->tokenable_id && $token->tokenable_type === User::class; + */ + return true; + } + + /** + * Determine whether the user can create API tokens. + */ + public function create(User $user): bool + { + // Authorization temporarily disabled + /* + // All authenticated users can create their own API tokens + return true; + */ + return true; + } + + /** + * Determine whether the user can update the API token. + */ + public function update(User $user, PersonalAccessToken $token): bool + { + // Authorization temporarily disabled + /* + // Users can only update their own tokens + return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; + */ + return true; + } + + /** + * Determine whether the user can delete the API token. + */ + public function delete(User $user, PersonalAccessToken $token): bool + { + // Authorization temporarily disabled + /* + // Users can only delete their own tokens + return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; + */ + return true; + } + + /** + * Determine whether the user can manage their own API tokens. + */ + public function manage(User $user): bool + { + // Authorization temporarily disabled + /* + // All authenticated users can manage their own API tokens + return true; + */ + return true; + } + + /** + * Determine whether the user can use root permissions for API tokens. + */ + public function useRootPermissions(User $user): bool + { + // Only admins and owners can use root permissions + return $user->isAdmin() || $user->isOwner(); + } + + /** + * Determine whether the user can use write permissions for API tokens. + */ + public function useWritePermissions(User $user): bool + { + // Authorization temporarily disabled + /* + // Only admins and owners can use write permissions + return $user->isAdmin() || $user->isOwner(); + */ + return true; + } +} diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index 05fc289b8..d64a436ad 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -4,6 +4,7 @@ namespace App\Policies; use App\Models\Application; use App\Models\User; +use Illuminate\Auth\Access\Response; class ApplicationPolicy { @@ -12,6 +13,10 @@ class ApplicationPolicy */ public function viewAny(User $user): bool { + // Authorization temporarily disabled + /* + return true; + */ return true; } @@ -20,6 +25,10 @@ class ApplicationPolicy */ public function view(User $user, Application $application): bool { + // Authorization temporarily disabled + /* + return true; + */ return true; } @@ -28,15 +37,31 @@ class ApplicationPolicy */ public function create(User $user): bool { + // Authorization temporarily disabled + /* + if ($user->isAdmin()) { + return true; + } + + return false; + */ return true; } /** * Determine whether the user can update the model. */ - public function update(User $user, Application $application): bool + public function update(User $user, Application $application): Response { - return true; + // Authorization temporarily disabled + /* + if ($user->isAdmin()) { + return Response::allow(); + } + + return Response::deny('As a member, you cannot update this application.

You need at least admin or owner permissions.'); + */ + return Response::allow(); } /** @@ -44,11 +69,15 @@ class ApplicationPolicy */ public function delete(User $user, Application $application): bool { + // Authorization temporarily disabled + /* if ($user->isAdmin()) { return true; } return false; + */ + return true; } /** @@ -56,6 +85,10 @@ class ApplicationPolicy */ public function restore(User $user, Application $application): bool { + // Authorization temporarily disabled + /* + return true; + */ return true; } @@ -64,6 +97,58 @@ class ApplicationPolicy */ public function forceDelete(User $user, Application $application): bool { + // Authorization temporarily disabled + /* + return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); + */ + return true; + } + + /** + * Determine whether the user can deploy the application. + */ + public function deploy(User $user, Application $application): bool + { + // Authorization temporarily disabled + /* + return $user->teams->contains('id', $application->team()->first()->id); + */ + return true; + } + + /** + * Determine whether the user can manage deployments. + */ + public function manageDeployments(User $user, Application $application): bool + { + // Authorization temporarily disabled + /* + return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); + */ + return true; + } + + /** + * Determine whether the user can manage environment variables. + */ + public function manageEnvironment(User $user, Application $application): bool + { + // Authorization temporarily disabled + /* + return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); + */ + return true; + } + + /** + * Determine whether the user can cleanup deployment queue. + */ + public function cleanupDeploymentQueue(User $user): bool + { + // Authorization temporarily disabled + /* + return $user->isAdmin(); + */ return true; } } diff --git a/app/Policies/ApplicationPreviewPolicy.php b/app/Policies/ApplicationPreviewPolicy.php new file mode 100644 index 000000000..4d371cc38 --- /dev/null +++ b/app/Policies/ApplicationPreviewPolicy.php @@ -0,0 +1,94 @@ +teams->contains('id', $applicationPreview->application->team()->first()->id); + return true; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ApplicationPreview $applicationPreview) + { + // if ($user->isAdmin()) { + // return Response::allow(); + // } + + // return Response::deny('As a member, you cannot update this preview.

You need at least admin or owner permissions.'); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ApplicationPreview $applicationPreview): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, ApplicationPreview $applicationPreview): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); + return true; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, ApplicationPreview $applicationPreview): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); + return true; + } + + /** + * Determine whether the user can deploy the preview. + */ + public function deploy(User $user, ApplicationPreview $applicationPreview): bool + { + // return $user->teams->contains('id', $applicationPreview->application->team()->first()->id); + return true; + } + + /** + * Determine whether the user can manage preview deployments. + */ + public function manageDeployments(User $user, ApplicationPreview $applicationPreview): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); + return true; + } +} diff --git a/app/Policies/ApplicationSettingPolicy.php b/app/Policies/ApplicationSettingPolicy.php new file mode 100644 index 000000000..848dc9aee --- /dev/null +++ b/app/Policies/ApplicationSettingPolicy.php @@ -0,0 +1,71 @@ +teams->contains('id', $applicationSetting->application->team()->first()->id); + return true; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ApplicationSetting $applicationSetting): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ApplicationSetting $applicationSetting): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, ApplicationSetting $applicationSetting): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); + return true; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, ApplicationSetting $applicationSetting): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); + return true; + } +} diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php new file mode 100644 index 000000000..f8e8af637 --- /dev/null +++ b/app/Policies/DatabasePolicy.php @@ -0,0 +1,102 @@ +teams->contains('id', $database->team()->first()->id); + return true; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, $database) + { + // if ($user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id)) { + // return Response::allow(); + // } + + // return Response::deny('As a member, you cannot update this database.

You need at least admin or owner permissions.'); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, $database): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, $database): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); + return true; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, $database): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); + return true; + } + + /** + * Determine whether the user can start/stop the database. + */ + public function manage(User $user, $database): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); + return true; + } + + /** + * Determine whether the user can manage database backups. + */ + public function manageBackups(User $user, $database): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); + return true; + } + + /** + * Determine whether the user can manage environment variables. + */ + public function manageEnvironment(User $user, $database): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); + return true; + } +} diff --git a/app/Policies/EnvironmentPolicy.php b/app/Policies/EnvironmentPolicy.php new file mode 100644 index 000000000..7199abb25 --- /dev/null +++ b/app/Policies/EnvironmentPolicy.php @@ -0,0 +1,71 @@ +teams->contains('id', $environment->project->team_id); + return true; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Environment $environment): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Environment $environment): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Environment $environment): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); + return true; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Environment $environment): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); + return true; + } +} diff --git a/app/Policies/EnvironmentVariablePolicy.php b/app/Policies/EnvironmentVariablePolicy.php new file mode 100644 index 000000000..21e2ea443 --- /dev/null +++ b/app/Policies/EnvironmentVariablePolicy.php @@ -0,0 +1,73 @@ +teams->contains('id', $githubApp->team_id) || $githubApp->is_system_wide; + return true; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, GithubApp $githubApp): bool + { + if ($githubApp->is_system_wide) { + // return $user->isAdmin(); + return true; + } + + // return $user->isAdmin() && $user->teams->contains('id', $githubApp->team_id); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, GithubApp $githubApp): bool + { + if ($githubApp->is_system_wide) { + // return $user->isAdmin(); + return true; + } + + // return $user->isAdmin() && $user->teams->contains('id', $githubApp->team_id); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, GithubApp $githubApp): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, GithubApp $githubApp): bool + { + return false; + } +} diff --git a/app/Policies/NotificationPolicy.php b/app/Policies/NotificationPolicy.php new file mode 100644 index 000000000..4f3be431d --- /dev/null +++ b/app/Policies/NotificationPolicy.php @@ -0,0 +1,56 @@ +team) { + return false; + } + + // return $user->teams()->where('teams.id', $notificationSettings->team->id)->exists(); + return true; + } + + /** + * Determine whether the user can update the notification settings. + */ + public function update(User $user, Model $notificationSettings): bool + { + // Check if the notification settings belong to the user's current team + if (! $notificationSettings->team) { + return false; + } + + // Only owners and admins can update notification settings + // return $user->isAdmin() || $user->isOwner(); + return true; + } + + /** + * Determine whether the user can manage (create, update, delete) notification settings. + */ + public function manage(User $user, Model $notificationSettings): bool + { + // return $this->update($user, $notificationSettings); + return true; + } + + /** + * Determine whether the user can send test notifications. + */ + public function sendTest(User $user, Model $notificationSettings): bool + { + // return $this->update($user, $notificationSettings); + return true; + } +} diff --git a/app/Policies/PrivateKeyPolicy.php b/app/Policies/PrivateKeyPolicy.php new file mode 100644 index 000000000..996054c95 --- /dev/null +++ b/app/Policies/PrivateKeyPolicy.php @@ -0,0 +1,69 @@ +teams->contains('id', $privateKey->team_id); + return true; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, PrivateKey $privateKey): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $privateKey->team_id); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, PrivateKey $privateKey): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $privateKey->team_id); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, PrivateKey $privateKey): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, PrivateKey $privateKey): bool + { + return false; + } +} diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php new file mode 100644 index 000000000..e188c293f --- /dev/null +++ b/app/Policies/ProjectPolicy.php @@ -0,0 +1,71 @@ +teams->contains('id', $project->team_id); + return true; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Project $project): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Project $project): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Project $project): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); + return true; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Project $project): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); + return true; + } +} diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php new file mode 100644 index 000000000..9ed2b66ab --- /dev/null +++ b/app/Policies/ResourceCreatePolicy.php @@ -0,0 +1,65 @@ +isAdmin(); + return true; + } + + /** + * Determine whether the user can create a specific resource type. + */ + public function create(User $user, string $resourceClass): bool + { + if (! in_array($resourceClass, self::CREATABLE_RESOURCES)) { + return false; + } + + // return $user->isAdmin(); + return true; + } + + /** + * Authorize creation of all supported resource types. + */ + public function authorizeAllResourceCreation(User $user): bool + { + return $this->createAny($user); + } +} diff --git a/app/Policies/S3StoragePolicy.php b/app/Policies/S3StoragePolicy.php index 28f5f8426..982c7c523 100644 --- a/app/Policies/S3StoragePolicy.php +++ b/app/Policies/S3StoragePolicy.php @@ -3,7 +3,6 @@ namespace App\Policies; use App\Models\S3Storage; -use App\Models\Server; use App\Models\User; class S3StoragePolicy @@ -21,7 +20,7 @@ class S3StoragePolicy */ public function view(User $user, S3Storage $storage): bool { - return $user->teams()->where('id', $storage->team_id)->exists(); + return $user->teams->contains('id', $storage->team_id); } /** @@ -29,15 +28,16 @@ class S3StoragePolicy */ public function create(User $user): bool { - return true; + return $user->isAdmin(); } /** * Determine whether the user can update the model. */ - public function update(User $user, Server $server): bool + public function update(User $user, S3Storage $storage): bool { - return $user->teams()->get()->firstWhere('id', $server->team_id) !== null; + // return $user->teams->contains('id', $storage->team_id) && $user->isAdmin(); + return $user->teams->contains('id', $storage->team_id); } /** @@ -45,7 +45,8 @@ class S3StoragePolicy */ public function delete(User $user, S3Storage $storage): bool { - return $user->teams()->where('id', $storage->team_id)->exists(); + // return $user->teams->contains('id', $storage->team_id) && $user->isAdmin(); + return $user->teams->contains('id', $storage->team_id); } /** @@ -63,4 +64,12 @@ class S3StoragePolicy { return false; } + + /** + * Determine whether the user can validate the connection of the model. + */ + public function validateConnection(User $user, S3Storage $storage): bool + { + return $user->teams->contains('id', $storage->team_id); + } } diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php index ad59b7140..6d2396a7d 100644 --- a/app/Policies/ServerPolicy.php +++ b/app/Policies/ServerPolicy.php @@ -20,7 +20,7 @@ class ServerPolicy */ public function view(User $user, Server $server): bool { - return $user->teams()->get()->firstWhere('id', $server->team_id) !== null; + return $user->teams->contains('id', $server->team_id); } /** @@ -28,6 +28,7 @@ class ServerPolicy */ public function create(User $user): bool { + // return $user->isAdmin(); return true; } @@ -36,7 +37,8 @@ class ServerPolicy */ public function update(User $user, Server $server): bool { - return $user->teams()->get()->firstWhere('id', $server->team_id) !== null; + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; } /** @@ -44,7 +46,8 @@ class ServerPolicy */ public function delete(User $user, Server $server): bool { - return $user->teams()->get()->firstWhere('id', $server->team_id) !== null; + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; } /** @@ -62,4 +65,40 @@ class ServerPolicy { return false; } + + /** + * Determine whether the user can manage proxy (start/stop/restart). + */ + public function manageProxy(User $user, Server $server): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; + } + + /** + * Determine whether the user can manage sentinel (start/stop). + */ + public function manageSentinel(User $user, Server $server): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; + } + + /** + * Determine whether the user can manage CA certificates. + */ + public function manageCaCertificate(User $user, Server $server): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; + } + + /** + * Determine whether the user can view security views. + */ + public function viewSecurity(User $user, Server $server): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; + } } diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php new file mode 100644 index 000000000..af380a90f --- /dev/null +++ b/app/Policies/ServiceApplicationPolicy.php @@ -0,0 +1,63 @@ +service); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ServiceApplication $serviceApplication): bool + { + // return Gate::allows('update', $serviceApplication->service); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ServiceApplication $serviceApplication): bool + { + // return Gate::allows('delete', $serviceApplication->service); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, ServiceApplication $serviceApplication): bool + { + // return Gate::allows('update', $serviceApplication->service); + return true; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, ServiceApplication $serviceApplication): bool + { + // return Gate::allows('delete', $serviceApplication->service); + return true; + } +} diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php new file mode 100644 index 000000000..f72f1f327 --- /dev/null +++ b/app/Policies/ServiceDatabasePolicy.php @@ -0,0 +1,69 @@ +isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ServiceDatabase $serviceDatabase): bool + { + + // return Gate::allows('update', $serviceDatabase->service); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ServiceDatabase $serviceDatabase): bool + { + // return Gate::allows('delete', $serviceDatabase->service); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, ServiceDatabase $serviceDatabase): bool + { + // return Gate::allows('update', $serviceDatabase->service); + return true; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, ServiceDatabase $serviceDatabase): bool + { + // return Gate::allows('delete', $serviceDatabase->service); + return true; + } + + public function manageBackups(User $user, ServiceDatabase $serviceDatabase): bool + { + return true; + } +} diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index 51a6d8116..7ab0fe7d0 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -28,6 +28,7 @@ class ServicePolicy */ public function create(User $user): bool { + // return $user->isAdmin(); return true; } @@ -36,6 +37,12 @@ class ServicePolicy */ public function update(User $user, Service $service): bool { + $team = $service->team(); + if (! $team) { + return false; + } + + // return $user->isAdmin() && $user->teams->contains('id', $team->id); return true; } @@ -44,11 +51,12 @@ class ServicePolicy */ public function delete(User $user, Service $service): bool { - if ($user->isAdmin()) { - return true; - } + // if ($user->isAdmin()) { + // return true; + // } - return false; + // return false; + return true; } /** @@ -56,6 +64,7 @@ class ServicePolicy */ public function restore(User $user, Service $service): bool { + // return true; return true; } @@ -64,19 +73,56 @@ class ServicePolicy */ public function forceDelete(User $user, Service $service): bool { - if ($user->isAdmin()) { - return true; - } + // if ($user->isAdmin()) { + // return true; + // } - return false; + // return false; + return true; } public function stop(User $user, Service $service): bool { - if ($user->isAdmin()) { - return true; + $team = $service->team(); + if (! $team) { + return false; } - return false; + // return $user->teams->contains('id', $team->id); + return true; + } + + /** + * Determine whether the user can manage environment variables. + */ + public function manageEnvironment(User $user, Service $service): bool + { + $team = $service->team(); + if (! $team) { + return false; + } + + // return $user->isAdmin() && $user->teams->contains('id', $team->id); + return true; + } + + /** + * Determine whether the user can deploy the service. + */ + public function deploy(User $user, Service $service): bool + { + $team = $service->team(); + if (! $team) { + return false; + } + + // return $user->teams->contains('id', $team->id); + return true; + } + + public function accessTerminal(User $user, Service $service): bool + { + // return $user->isAdmin() || $user->teams->contains('id', $service->team()->id); + return true; } } diff --git a/app/Policies/SharedEnvironmentVariablePolicy.php b/app/Policies/SharedEnvironmentVariablePolicy.php new file mode 100644 index 000000000..b465d8a0c --- /dev/null +++ b/app/Policies/SharedEnvironmentVariablePolicy.php @@ -0,0 +1,79 @@ +teams->contains('id', $sharedEnvironmentVariable->team_id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); + return true; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); + return true; + } + + /** + * Determine whether the user can manage environment variables. + */ + public function manageEnvironment(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); + return true; + } +} diff --git a/app/Policies/StandaloneDockerPolicy.php b/app/Policies/StandaloneDockerPolicy.php new file mode 100644 index 000000000..154648599 --- /dev/null +++ b/app/Policies/StandaloneDockerPolicy.php @@ -0,0 +1,70 @@ +teams->contains('id', $standaloneDocker->server->team_id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, StandaloneDocker $standaloneDocker): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, StandaloneDocker $standaloneDocker): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, StandaloneDocker $standaloneDocker): bool + { + // return false; + return true; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, StandaloneDocker $standaloneDocker): bool + { + // return false; + return true; + } +} diff --git a/app/Policies/SwarmDockerPolicy.php b/app/Policies/SwarmDockerPolicy.php new file mode 100644 index 000000000..979bb5889 --- /dev/null +++ b/app/Policies/SwarmDockerPolicy.php @@ -0,0 +1,70 @@ +teams->contains('id', $swarmDocker->server->team_id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // return $user->isAdmin(); + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, SwarmDocker $swarmDocker): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, SwarmDocker $swarmDocker): bool + { + // return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id); + return true; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, SwarmDocker $swarmDocker): bool + { + // return false; + return true; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, SwarmDocker $swarmDocker): bool + { + // return false; + return true; + } +} diff --git a/app/Policies/TeamPolicy.php b/app/Policies/TeamPolicy.php new file mode 100644 index 000000000..b7ef48943 --- /dev/null +++ b/app/Policies/TeamPolicy.php @@ -0,0 +1,104 @@ +teams->contains('id', $team->id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // All authenticated users can create teams + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Team $team): bool + { + // Only admins and owners can update team settings + if (! $user->teams->contains('id', $team->id)) { + return false; + } + + // return $user->isAdmin() || $user->isOwner(); + return true; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Team $team): bool + { + // Only admins and owners can delete teams + if (! $user->teams->contains('id', $team->id)) { + return false; + } + + // return $user->isAdmin() || $user->isOwner(); + return true; + } + + /** + * Determine whether the user can manage team members. + */ + public function manageMembers(User $user, Team $team): bool + { + // Only admins and owners can manage team members + if (! $user->teams->contains('id', $team->id)) { + return false; + } + + // return $user->isAdmin() || $user->isOwner(); + return true; + } + + /** + * Determine whether the user can view admin panel. + */ + public function viewAdmin(User $user, Team $team): bool + { + // Only admins and owners can view admin panel + if (! $user->teams->contains('id', $team->id)) { + return false; + } + + // return $user->isAdmin() || $user->isOwner(); + return true; + } + + /** + * Determine whether the user can manage invitations. + */ + public function manageInvitations(User $user, Team $team): bool + { + // Only admins and owners can manage invitations + if (! $user->teams->contains('id', $team->id)) { + return false; + } + + // return $user->isAdmin() || $user->isOwner(); + return true; + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index dafcbee79..c017a580e 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -3,7 +3,9 @@ namespace App\Providers; // use Illuminate\Support\Facades\Gate; +use App\Policies\ResourceCreatePolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; +use Illuminate\Support\Facades\Gate; class AuthServiceProvider extends ServiceProvider { @@ -13,7 +15,46 @@ class AuthServiceProvider extends ServiceProvider * @var array */ protected $policies = [ - // 'App\Models\Model' => 'App\Policies\ModelPolicy', + \App\Models\Server::class => \App\Policies\ServerPolicy::class, + \App\Models\PrivateKey::class => \App\Policies\PrivateKeyPolicy::class, + \App\Models\StandaloneDocker::class => \App\Policies\StandaloneDockerPolicy::class, + \App\Models\SwarmDocker::class => \App\Policies\SwarmDockerPolicy::class, + \App\Models\Application::class => \App\Policies\ApplicationPolicy::class, + \App\Models\ApplicationPreview::class => \App\Policies\ApplicationPreviewPolicy::class, + \App\Models\ApplicationSetting::class => \App\Policies\ApplicationSettingPolicy::class, + \App\Models\Service::class => \App\Policies\ServicePolicy::class, + \App\Models\ServiceApplication::class => \App\Policies\ServiceApplicationPolicy::class, + \App\Models\ServiceDatabase::class => \App\Policies\ServiceDatabasePolicy::class, + \App\Models\Project::class => \App\Policies\ProjectPolicy::class, + \App\Models\Environment::class => \App\Policies\EnvironmentPolicy::class, + \App\Models\EnvironmentVariable::class => \App\Policies\EnvironmentVariablePolicy::class, + \App\Models\SharedEnvironmentVariable::class => \App\Policies\SharedEnvironmentVariablePolicy::class, + // Database policies - all use the shared DatabasePolicy + \App\Models\StandalonePostgresql::class => \App\Policies\DatabasePolicy::class, + \App\Models\StandaloneMysql::class => \App\Policies\DatabasePolicy::class, + \App\Models\StandaloneMariadb::class => \App\Policies\DatabasePolicy::class, + \App\Models\StandaloneMongodb::class => \App\Policies\DatabasePolicy::class, + \App\Models\StandaloneRedis::class => \App\Policies\DatabasePolicy::class, + \App\Models\StandaloneKeydb::class => \App\Policies\DatabasePolicy::class, + \App\Models\StandaloneDragonfly::class => \App\Policies\DatabasePolicy::class, + \App\Models\StandaloneClickhouse::class => \App\Policies\DatabasePolicy::class, + + // Notification policies - all use the shared NotificationPolicy + \App\Models\EmailNotificationSettings::class => \App\Policies\NotificationPolicy::class, + \App\Models\DiscordNotificationSettings::class => \App\Policies\NotificationPolicy::class, + \App\Models\TelegramNotificationSettings::class => \App\Policies\NotificationPolicy::class, + \App\Models\SlackNotificationSettings::class => \App\Policies\NotificationPolicy::class, + \App\Models\PushoverNotificationSettings::class => \App\Policies\NotificationPolicy::class, + + // API Token policy + \Laravel\Sanctum\PersonalAccessToken::class => \App\Policies\ApiTokenPolicy::class, + + // Team policy + \App\Models\Team::class => \App\Policies\TeamPolicy::class, + + // Git source policies + \App\Models\GithubApp::class => \App\Policies\GithubAppPolicy::class, + ]; /** @@ -21,6 +62,12 @@ class AuthServiceProvider extends ServiceProvider */ public function boot(): void { - // + // Register gates for resource creation policy + Gate::define('createAnyResource', [ResourceCreatePolicy::class, 'createAny']); + + // Register gate for terminal access + Gate::define('canAccessTerminal', function ($user) { + return $user->isAdmin() || $user->isOwner(); + }); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index ed27a158a..30d909388 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -80,9 +80,23 @@ class FortifyServiceProvider extends ServiceProvider ) { $user->updated_at = now(); $user->save(); - $user->currentTeam = $user->teams->firstWhere('personal_team', true); - if (! $user->currentTeam) { - $user->currentTeam = $user->recreate_personal_team(); + + // Check if user has a pending invitation they haven't accepted yet + $invitation = \App\Models\TeamInvitation::whereEmail($email)->first(); + if ($invitation && $invitation->isValid()) { + // User is logging in for the first time after being invited + // Attach them to the invited team if not already attached + if (! $user->teams()->where('team_id', $invitation->team->id)->exists()) { + $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]); + } + $user->currentTeam = $invitation->team; + $invitation->delete(); + } else { + // Normal login - use personal team + $user->currentTeam = $user->teams->firstWhere('personal_team', true); + if (! $user->currentTeam) { + $user->currentTeam = $user->recreate_personal_team(); + } } session(['currentTeam' => $user->currentTeam]); diff --git a/app/Rules/ValidGitBranch.php b/app/Rules/ValidGitBranch.php new file mode 100644 index 000000000..a3069f08e --- /dev/null +++ b/app/Rules/ValidGitBranch.php @@ -0,0 +1,96 @@ +', '\n', '\r', '\0', '"', "'", '\\', + '!', '*', '?', '[', ']', '~', '^', ':', ' ', + '#', + ]; + + foreach ($dangerousChars as $char) { + if (str_contains($branch, $char)) { + Log::warning('Git branch validation failed - dangerous character', [ + 'branch' => $branch, + 'character' => $char, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute contains invalid characters.'); + + return; + } + } + + // Git branch name rules: + // - Cannot contain: .., //, @{ + // - Cannot start or end with: / or . + // - Cannot be empty after trimming + + if (str_contains($branch, '..') || + str_contains($branch, '//') || + str_contains($branch, '@{')) { + $fail('The :attribute contains invalid patterns.'); + + return; + } + + if (str_starts_with($branch, '/') || + str_ends_with($branch, '/') || + str_starts_with($branch, '.') || + str_ends_with($branch, '.')) { + $fail('The :attribute cannot start or end with / or .'); + + return; + } + + // Allow only safe characters for branch names + // Letters, numbers, hyphens, underscores, forward slashes, and dots + if (! preg_match('/^[a-zA-Z0-9\-_\/\.]+$/', $branch)) { + $fail('The :attribute contains invalid characters. Only letters, numbers, hyphens, underscores, forward slashes, and dots are allowed.'); + + return; + } + + // Additional Git-specific validations + // Branch name cannot be 'HEAD' + if ($branch === 'HEAD') { + $fail('The :attribute cannot be HEAD.'); + + return; + } + + // Check for consecutive dots (not allowed in Git) + if (str_contains($branch, '..')) { + $fail('The :attribute cannot contain consecutive dots.'); + + return; + } + + // Check for .lock suffix (reserved by Git) + if (str_ends_with($branch, '.lock')) { + $fail('The :attribute cannot end with .lock.'); + + return; + } + } +} diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php new file mode 100644 index 000000000..3cbe9246e --- /dev/null +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -0,0 +1,157 @@ +allowSSH = $allowSSH; + $this->allowIP = $allowIP; + } + + /** + * Run the validation rule. + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (empty($value)) { + return; + } + + // Check for dangerous shell metacharacters that could be used for command injection + $dangerousChars = [ + ';', '|', '&', '$', '`', '(', ')', '{', '}', + '[', ']', '<', '>', '\n', '\r', '\0', '"', "'", + '\\', '!', '?', '*', '~', '^', '%', '=', '+', + '#', // Comment character that could hide commands + ]; + + foreach ($dangerousChars as $char) { + if (str_contains($value, $char)) { + Log::warning('Git repository URL validation failed - dangerous character', [ + 'url' => $value, + 'character' => $char, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute contains invalid characters.'); + + return; + } + } + + // Check for command substitution patterns + $dangerousPatterns = [ + '/\$\(.*\)/', // Command substitution $(...) + '/\${.*}/', // Variable expansion ${...} + '/;;/', // Double semicolon + '/&&/', // Command chaining + '/\|\|/', // Command chaining + '/>>/', // Redirect append + '/< $value, + 'pattern' => $pattern, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute contains invalid patterns.'); + + return; + } + } + + // Validate based on URL type + if (str_starts_with($value, 'git@')) { + if (! $this->allowSSH) { + $fail('SSH URLs are not allowed.'); + + return; + } + + // Validate SSH URL format (git@host:user/repo.git) + if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.]+$/', $value)) { + $fail('The :attribute is not a valid SSH repository URL.'); + + return; + } + } elseif (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) { + // Validate HTTP(S) URL + if (! filter_var($value, FILTER_VALIDATE_URL)) { + $fail('The :attribute is not a valid URL.'); + + return; + } + + $parsed = parse_url($value); + + // Check for IP addresses if not allowed + if (! $this->allowIP && filter_var($parsed['host'] ?? '', FILTER_VALIDATE_IP)) { + Log::warning('Git repository URL contains IP address', [ + 'url' => $value, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute cannot use IP addresses.'); + + return; + } + + // Check for localhost/internal addresses + $host = strtolower($parsed['host'] ?? ''); + $internalHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']; + if (in_array($host, $internalHosts) || str_ends_with($host, '.local')) { + Log::warning('Git repository URL points to internal host', [ + 'url' => $value, + 'host' => $host, + 'ip' => request()->ip(), + 'user_id' => auth()->id(), + ]); + $fail('The :attribute cannot point to internal hosts.'); + + return; + } + + // Ensure no query parameters or fragments + if (! empty($parsed['query']) || ! empty($parsed['fragment'])) { + $fail('The :attribute should not contain query parameters or fragments.'); + + return; + } + + // Validate path contains only safe characters + $path = $parsed['path'] ?? ''; + if (! empty($path) && ! preg_match('/^[a-zA-Z0-9\-_\/\.]+$/', $path)) { + $fail('The :attribute path contains invalid characters.'); + + return; + } + } elseif (str_starts_with($value, 'git://')) { + // Validate git:// protocol URL + if (! preg_match('/^git:\/\/[a-zA-Z0-9\.\-]+\/[a-zA-Z0-9\-_\/\.]+$/', $value)) { + $fail('The :attribute is not a valid git:// URL.'); + + return; + } + } else { + $fail('The :attribute must start with https://, http://, git://, or git@.'); + + return; + } + } +} diff --git a/app/Rules/ValidIpOrCidr.php b/app/Rules/ValidIpOrCidr.php new file mode 100644 index 000000000..e172ffd1a --- /dev/null +++ b/app/Rules/ValidIpOrCidr.php @@ -0,0 +1,63 @@ + 32) { + $invalidEntries[] = $entry; + } + } else { + // Check if it's a valid IP + if (! filter_var($entry, FILTER_VALIDATE_IP)) { + $invalidEntries[] = $entry; + } + } + } + + if (! empty($invalidEntries)) { + $fail('The following entries are not valid IP addresses or CIDR notations: '.implode(', ', $invalidEntries)); + } + } +} diff --git a/app/Services/ChangelogService.php b/app/Services/ChangelogService.php new file mode 100644 index 000000000..f0887c11c --- /dev/null +++ b/app/Services/ChangelogService.php @@ -0,0 +1,300 @@ +fetchChangelogData(); + + if (! $data || ! isset($data['entries'])) { + return collect(); + } + + return collect($data['entries']) + ->filter(fn ($entry) => $this->validateEntryData($entry)) + ->map(function ($entry) { + $entry['published_at'] = Carbon::parse($entry['published_at']); + $entry['content_html'] = $this->parseMarkdown($entry['content']); + + return (object) $entry; + }) + ->filter(fn ($entry) => $entry->published_at <= now()) + ->sortBy('published_at') + ->reverse() + ->values(); + } + + // Load entries from recent months for performance + $availableMonths = $this->getAvailableMonths(); + $monthsToLoad = $availableMonths->take($recentMonths); + + return $monthsToLoad + ->flatMap(fn ($month) => $this->getEntriesForMonth($month)) + ->sortBy('published_at') + ->reverse() + ->values(); + } + + public function getAllEntries(): Collection + { + $availableMonths = $this->getAvailableMonths(); + + return $availableMonths + ->flatMap(fn ($month) => $this->getEntriesForMonth($month)) + ->sortBy('published_at') + ->reverse() + ->values(); + } + + public function getEntriesForUser(User $user): Collection + { + $entries = $this->getEntries(); + $readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id); + + return $entries->map(function ($entry) use ($readIdentifiers) { + $entry->is_read = in_array($entry->tag_name, $readIdentifiers); + + return $entry; + })->sortBy([ + ['is_read', 'asc'], // unread first + ['published_at', 'desc'], // then by date + ])->values(); + } + + public function getUnreadCountForUser(User $user): int + { + if (isDev()) { + $entries = $this->getEntries(); + $readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id); + + return $entries->reject(fn ($entry) => in_array($entry->tag_name, $readIdentifiers))->count(); + } else { + return Cache::remember( + 'user_unread_changelog_count_'.$user->id, + now()->addHour(), + function () use ($user) { + $entries = $this->getEntries(); + $readIdentifiers = UserChangelogRead::getReadIdentifiersForUser($user->id); + + return $entries->reject(fn ($entry) => in_array($entry->tag_name, $readIdentifiers))->count(); + } + ); + } + } + + public function getAvailableMonths(): Collection + { + $pattern = base_path('changelogs/*.json'); + $files = glob($pattern); + + if ($files === false) { + return collect(); + } + + return collect($files) + ->map(fn ($file) => basename($file, '.json')) + ->filter(fn ($name) => preg_match('/^\d{4}-\d{2}$/', $name)) + ->sort() + ->reverse() + ->values(); + } + + public function getEntriesForMonth(string $month): Collection + { + $path = base_path("changelogs/{$month}.json"); + + if (! file_exists($path)) { + return collect(); + } + + $content = file_get_contents($path); + + if ($content === false) { + Log::error("Failed to read changelog file: {$month}.json"); + + return collect(); + } + + $data = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + Log::error("Invalid JSON in {$month}.json: ".json_last_error_msg()); + + return collect(); + } + + if (! isset($data['entries']) || ! is_array($data['entries'])) { + return collect(); + } + + return collect($data['entries']) + ->filter(fn ($entry) => $this->validateEntryData($entry)) + ->map(function ($entry) { + $entry['published_at'] = Carbon::parse($entry['published_at']); + $entry['content_html'] = $this->parseMarkdown($entry['content']); + + return (object) $entry; + }) + ->filter(fn ($entry) => $entry->published_at <= now()) + ->sortBy('published_at') + ->reverse() + ->values(); + } + + private function fetchChangelogData(): ?array + { + // Legacy support for old changelog.json + $path = base_path('changelog.json'); + + if (file_exists($path)) { + $content = file_get_contents($path); + + if ($content === false) { + Log::error('Failed to read changelog.json file'); + + return null; + } + + $data = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + Log::error('Invalid JSON in changelog.json: '.json_last_error_msg()); + + return null; + } + + return $data; + } + + // New monthly structure - combine all months + $allEntries = []; + foreach ($this->getAvailableMonths() as $month) { + $monthEntries = $this->getEntriesForMonth($month); + foreach ($monthEntries as $entry) { + $allEntries[] = (array) $entry; + } + } + + return ['entries' => $allEntries]; + } + + public function markAsReadForUser(string $version, User $user): void + { + UserChangelogRead::markAsRead($user->id, $version); + Cache::forget('user_unread_changelog_count_'.$user->id); + } + + public function markAllAsReadForUser(User $user): void + { + $entries = $this->getEntries(); + + foreach ($entries as $entry) { + UserChangelogRead::markAsRead($user->id, $entry->tag_name); + } + + Cache::forget('user_unread_changelog_count_'.$user->id); + } + + private function validateEntryData(array $data): bool + { + $required = ['tag_name', 'title', 'content', 'published_at']; + + foreach ($required as $field) { + if (! isset($data[$field]) || empty($data[$field])) { + return false; + } + } + + return true; + } + + public function clearAllReadStatus(): array + { + try { + $count = UserChangelogRead::count(); + UserChangelogRead::truncate(); + + // Clear all user caches + $this->clearAllUserCaches(); + + return [ + 'success' => true, + 'message' => "Successfully cleared {$count} read status records", + ]; + } catch (\Exception $e) { + Log::error('Failed to clear read status: '.$e->getMessage()); + + return [ + 'success' => false, + 'message' => 'Failed to clear read status: '.$e->getMessage(), + ]; + } + } + + private function clearAllUserCaches(): void + { + $users = User::select('id')->get(); + + foreach ($users as $user) { + Cache::forget('user_unread_changelog_count_'.$user->id); + } + } + + private function parseMarkdown(string $content): string + { + $renderer = app(MarkdownRenderer::class); + + $html = $renderer->toHtml($content); + + // Apply custom Tailwind CSS classes for dark mode compatibility + $html = $this->applyCustomStyling($html); + + return $html; + } + + private function applyCustomStyling(string $html): string + { + // Headers + $html = preg_replace('/]*>/', '

', $html); + $html = preg_replace('/]*>/', '

', $html); + $html = preg_replace('/]*>/', '

', $html); + + // Paragraphs + $html = preg_replace('/]*>/', '

', $html); + + // Lists + $html = preg_replace('/]*>/', '

    ', $html); + $html = preg_replace('/]*>/', '
      ', $html); + $html = preg_replace('/]*>/', '
    1. ', $html); + + // Code blocks and inline code + $html = preg_replace('/]*>/', '
      ', $html);
      +        $html = preg_replace('/]*>/', '', $html);
      +
      +        // Links - Apply styling to existing markdown links
      +        $html = preg_replace('/]*)>/', '', $html);
      +
      +        // Convert plain URLs to clickable links (that aren't already in  tags)
      +        $html = preg_replace('/(?)(?"]+)(?![^<]*<\/a>)/', '$1', $html);
      +
      +        // Strong/bold text
      +        $html = preg_replace('/]*>/', '', $html);
      +
      +        // Emphasis/italic text
      +        $html = preg_replace('/]*>/', '', $html);
      +
      +        return $html;
      +    }
      +}
      diff --git a/app/Services/ConfigurationGenerator.php b/app/Services/ConfigurationGenerator.php
      index a7e4b31be..320e3f32a 100644
      --- a/app/Services/ConfigurationGenerator.php
      +++ b/app/Services/ConfigurationGenerator.php
      @@ -129,7 +129,6 @@ class ConfigurationGenerator
                   $variables->push([
                       'key' => $env->key,
                       'value' => $env->value,
      -                'is_build_time' => $env->is_build_time,
                       'is_preview' => $env->is_preview,
                       'is_multiline' => $env->is_multiline,
                   ]);
      @@ -145,7 +144,6 @@ class ConfigurationGenerator
                   $variables->push([
                       'key' => $env->key,
                       'value' => $env->value,
      -                'is_build_time' => $env->is_build_time,
                       'is_preview' => $env->is_preview,
                       'is_multiline' => $env->is_multiline,
                   ]);
      diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
      new file mode 100644
      index 000000000..965142558
      --- /dev/null
      +++ b/app/Support/ValidationPatterns.php
      @@ -0,0 +1,93 @@
      + 'The name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
      +            'name.min' => 'The name must be at least :min characters.',
      +            'name.max' => 'The name may not be greater than :max characters.',
      +        ];
      +    }
      +
      +    /**
      +     * Get validation messages for description fields
      +     */
      +    public static function descriptionMessages(): array
      +    {
      +        return [
      +            'description.regex' => 'The description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
      +            'description.max' => 'The description may not be greater than :max characters.',
      +        ];
      +    }
      +
      +    /**
      +     * Get combined validation messages for both name and description fields
      +     */
      +    public static function combinedMessages(): array
      +    {
      +        return array_merge(self::nameMessages(), self::descriptionMessages());
      +    }
      +}
      diff --git a/app/Traits/AuthorizesResourceCreation.php b/app/Traits/AuthorizesResourceCreation.php
      new file mode 100644
      index 000000000..01ae7c8d9
      --- /dev/null
      +++ b/app/Traits/AuthorizesResourceCreation.php
      @@ -0,0 +1,20 @@
      +authorize('createAnyResource');
      +    }
      +}
      diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php
      new file mode 100644
      index 000000000..0bcc5d319
      --- /dev/null
      +++ b/app/Traits/ClearsGlobalSearchCache.php
      @@ -0,0 +1,81 @@
      +hasSearchableChanges()) {
      +                $teamId = $model->getTeamIdForCache();
      +                if (filled($teamId)) {
      +                    GlobalSearch::clearTeamCache($teamId);
      +                }
      +            }
      +        });
      +
      +        static::created(function ($model) {
      +            // Always clear cache when model is created
      +            $teamId = $model->getTeamIdForCache();
      +            if (filled($teamId)) {
      +                GlobalSearch::clearTeamCache($teamId);
      +            }
      +        });
      +
      +        static::deleted(function ($model) {
      +            // Always clear cache when model is deleted
      +            $teamId = $model->getTeamIdForCache();
      +            if (filled($teamId)) {
      +                GlobalSearch::clearTeamCache($teamId);
      +            }
      +        });
      +    }
      +
      +    private function hasSearchableChanges(): bool
      +    {
      +        // Define searchable fields based on model type
      +        $searchableFields = ['name', 'description'];
      +
      +        // Add model-specific searchable fields
      +        if ($this instanceof \App\Models\Application) {
      +            $searchableFields[] = 'fqdn';
      +            $searchableFields[] = 'docker_compose_domains';
      +        } elseif ($this instanceof \App\Models\Server) {
      +            $searchableFields[] = 'ip';
      +        } elseif ($this instanceof \App\Models\Service) {
      +            // Services don't have direct fqdn, but name and description are covered
      +        }
      +        // Database models only have name and description as searchable
      +
      +        // Check if any searchable field is dirty
      +        foreach ($searchableFields as $field) {
      +            if ($this->isDirty($field)) {
      +                return true;
      +            }
      +        }
      +
      +        return false;
      +    }
      +
      +    private function getTeamIdForCache()
      +    {
      +        // For database models, team is accessed through environment.project.team
      +        if (method_exists($this, 'team')) {
      +            $team = $this->team();
      +            if (filled($team)) {
      +                return is_object($team) ? $team->id : null;
      +            }
      +        }
      +
      +        // For models with direct team_id property
      +        if (property_exists($this, 'team_id') || isset($this->team_id)) {
      +            return $this->team_id;
      +        }
      +
      +        return null;
      +    }
      +}
      diff --git a/app/Traits/EnvironmentVariableProtection.php b/app/Traits/EnvironmentVariableProtection.php
      index b6b8d2687..ecc484966 100644
      --- a/app/Traits/EnvironmentVariableProtection.php
      +++ b/app/Traits/EnvironmentVariableProtection.php
      @@ -14,7 +14,7 @@ trait EnvironmentVariableProtection
            */
           protected function isProtectedEnvironmentVariable(string $key): bool
           {
      -        return str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL');
      +        return str($key)->startsWith('SERVICE_FQDN_') || str($key)->startsWith('SERVICE_URL_') || str($key)->startsWith('SERVICE_NAME_');
           }
       
           /**
      diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
      index a228a5d10..f9df19c16 100644
      --- a/app/Traits/ExecuteRemoteCommand.php
      +++ b/app/Traits/ExecuteRemoteCommand.php
      @@ -11,10 +11,52 @@ use Illuminate\Support\Facades\Process;
       
       trait ExecuteRemoteCommand
       {
      +    use SshRetryable;
      +
           public ?string $save = null;
       
           public static int $batch_counter = 0;
       
      +    private function redact_sensitive_info($text)
      +    {
      +        $text = remove_iip($text);
      +
      +        if (! isset($this->application)) {
      +            return $text;
      +        }
      +
      +        $lockedVars = collect([]);
      +
      +        if (isset($this->application->environment_variables)) {
      +            $lockedVars = $lockedVars->merge(
      +                $this->application->environment_variables
      +                    ->where('is_shown_once', true)
      +                    ->pluck('real_value', 'key')
      +                    ->filter()
      +            );
      +        }
      +
      +        if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) {
      +            $lockedVars = $lockedVars->merge(
      +                $this->application->environment_variables_preview
      +                    ->where('is_shown_once', true)
      +                    ->pluck('real_value', 'key')
      +                    ->filter()
      +            );
      +        }
      +
      +        foreach ($lockedVars as $key => $value) {
      +            $escapedValue = preg_quote($value, '/');
      +            $text = preg_replace(
      +                '/'.$escapedValue.'/',
      +                REDACTED,
      +                $text
      +            );
      +        }
      +
      +        return $text;
      +    }
      +
           public function execute_remote_command(...$commands)
           {
               static::$batch_counter++;
      @@ -43,76 +85,188 @@ trait ExecuteRemoteCommand
                           $command = parseLineForSudo($command, $this->server);
                       }
                   }
      -            $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
      -            $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) {
      -                $output = str($output)->trim();
      -                if ($output->startsWith('╔')) {
      -                    $output = "\n".$output;
      +
      +            // Check for cancellation before executing commands
      +            if (isset($this->application_deployment_queue)) {
      +                $this->application_deployment_queue->refresh();
      +                if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
      +                    throw new \RuntimeException('Deployment cancelled by user', 69420);
                       }
      +            }
       
      -                // Sanitize output to ensure valid UTF-8 encoding before JSON encoding
      -                $sanitized_output = sanitize_utf8_text($output);
      -
      -                $new_log_entry = [
      -                    'command' => remove_iip($command),
      -                    'output' => remove_iip($sanitized_output),
      -                    'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
      -                    'timestamp' => Carbon::now('UTC'),
      -                    'hidden' => $hidden,
      -                    'batch' => static::$batch_counter,
      -                ];
      -                if (! $this->application_deployment_queue->logs) {
      -                    $new_log_entry['order'] = 1;
      -                } else {
      -                    try {
      -                        $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
      -                    } catch (\JsonException $e) {
      -                        // If existing logs are corrupted, start fresh
      -                        $previous_logs = [];
      -                        $new_log_entry['order'] = 1;
      -                    }
      -                    if (is_array($previous_logs)) {
      -                        $new_log_entry['order'] = count($previous_logs) + 1;
      -                    } else {
      -                        $previous_logs = [];
      -                        $new_log_entry['order'] = 1;
      -                    }
      -                }
      -                $previous_logs[] = $new_log_entry;
      +            $maxRetries = config('constants.ssh.max_retries');
      +            $attempt = 0;
      +            $lastError = null;
      +            $commandExecuted = false;
       
      +            while ($attempt < $maxRetries && ! $commandExecuted) {
                       try {
      -                    $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
      -                } catch (\JsonException $e) {
      -                    // If JSON encoding still fails, use fallback with invalid sequences replacement
      -                    $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
      -                }
      +                    $this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors);
      +                    $commandExecuted = true;
      +                } catch (\RuntimeException $e) {
      +                    $lastError = $e;
      +                    $errorMessage = $e->getMessage();
      +                    // Only retry if it's an SSH connection error and we haven't exhausted retries
      +                    if ($this->isRetryableSshError($errorMessage) && $attempt < $maxRetries - 1) {
      +                        $attempt++;
      +                        $delay = $this->calculateRetryDelay($attempt - 1);
       
      -                $this->application_deployment_queue->save();
      +                        // Track SSH retry event in Sentry
      +                        $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [
      +                            'server' => $this->server->name ?? $this->server->ip ?? 'unknown',
      +                            'command' => $this->redact_sensitive_info($command),
      +                            'trait' => 'ExecuteRemoteCommand',
      +                        ]);
       
      -                if ($this->save) {
      -                    if (data_get($this->saved_outputs, $this->save, null) === null) {
      -                        data_set($this->saved_outputs, $this->save, str());
      -                    }
      -                    if ($append) {
      -                        $this->saved_outputs[$this->save] .= str($sanitized_output)->trim();
      -                        $this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]);
      +                        // Add log entry for the retry
      +                        if (isset($this->application_deployment_queue)) {
      +                            $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage);
      +
      +                            // Check for cancellation during retry wait
      +                            $this->application_deployment_queue->refresh();
      +                            if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
      +                                throw new \RuntimeException('Deployment cancelled by user during retry', 69420);
      +                            }
      +                        }
      +
      +                        sleep($delay);
                           } else {
      -                        $this->saved_outputs[$this->save] = str($sanitized_output)->trim();
      +                        // Not retryable or max retries reached
      +                        throw $e;
                           }
                       }
      -            });
      -            $this->application_deployment_queue->update([
      -                'current_process_id' => $process->id(),
      -            ]);
      +            }
       
      -            $process_result = $process->wait();
      -            if ($process_result->exitCode() !== 0) {
      -                if (! $ignore_errors) {
      +            // If we exhausted all retries and still failed
      +            if (! $commandExecuted && $lastError) {
      +                // Now we can set the status to FAILED since all retries have been exhausted
      +                if (isset($this->application_deployment_queue)) {
                           $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
                           $this->application_deployment_queue->save();
      -                    throw new \RuntimeException($process_result->errorOutput());
                       }
      +                throw $lastError;
                   }
               });
           }
      +
      +    /**
      +     * Execute the actual command with process handling
      +     */
      +    private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors)
      +    {
      +        $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
      +        $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) {
      +            $output = str($output)->trim();
      +            if ($output->startsWith('╔')) {
      +                $output = "\n".$output;
      +            }
      +
      +            // Sanitize output to ensure valid UTF-8 encoding before JSON encoding
      +            $sanitized_output = sanitize_utf8_text($output);
      +
      +            $new_log_entry = [
      +                'command' => $this->redact_sensitive_info($command),
      +                'output' => $this->redact_sensitive_info($sanitized_output),
      +                'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout',
      +                'timestamp' => Carbon::now('UTC'),
      +                'hidden' => $hidden,
      +                'batch' => static::$batch_counter,
      +            ];
      +            if (! $this->application_deployment_queue->logs) {
      +                $new_log_entry['order'] = 1;
      +            } else {
      +                try {
      +                    $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
      +                } catch (\JsonException $e) {
      +                    // If existing logs are corrupted, start fresh
      +                    $previous_logs = [];
      +                    $new_log_entry['order'] = 1;
      +                }
      +                if (is_array($previous_logs)) {
      +                    $new_log_entry['order'] = count($previous_logs) + 1;
      +                } else {
      +                    $previous_logs = [];
      +                    $new_log_entry['order'] = 1;
      +                }
      +            }
      +            $previous_logs[] = $new_log_entry;
      +
      +            try {
      +                $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
      +            } catch (\JsonException $e) {
      +                // If JSON encoding still fails, use fallback with invalid sequences replacement
      +                $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
      +            }
      +
      +            $this->application_deployment_queue->save();
      +
      +            if ($this->save) {
      +                if (data_get($this->saved_outputs, $this->save, null) === null) {
      +                    data_set($this->saved_outputs, $this->save, str());
      +                }
      +                if ($append) {
      +                    $this->saved_outputs[$this->save] .= str($sanitized_output)->trim();
      +                    $this->saved_outputs[$this->save] = str($this->saved_outputs[$this->save]);
      +                } else {
      +                    $this->saved_outputs[$this->save] = str($sanitized_output)->trim();
      +                }
      +            }
      +        });
      +        $this->application_deployment_queue->update([
      +            'current_process_id' => $process->id(),
      +        ]);
      +
      +        $process_result = $process->wait();
      +        if ($process_result->exitCode() !== 0) {
      +            if (! $ignore_errors) {
      +                // Don't immediately set to FAILED - let the retry logic handle it
      +                // This prevents premature status changes during retryable SSH errors
      +                throw new \RuntimeException($process_result->errorOutput());
      +            }
      +        }
      +    }
      +
      +    /**
      +     * Add a log entry for SSH retry attempts
      +     */
      +    private function addRetryLogEntry(int $attempt, int $maxRetries, int $delay, string $errorMessage)
      +    {
      +        $retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}";
      +
      +        $new_log_entry = [
      +            'output' => $this->redact_sensitive_info($retryMessage),
      +            'type' => 'stdout',
      +            'timestamp' => Carbon::now('UTC'),
      +            'hidden' => false,
      +            'batch' => static::$batch_counter,
      +        ];
      +
      +        if (! $this->application_deployment_queue->logs) {
      +            $new_log_entry['order'] = 1;
      +            $previous_logs = [];
      +        } else {
      +            try {
      +                $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
      +            } catch (\JsonException $e) {
      +                $previous_logs = [];
      +                $new_log_entry['order'] = 1;
      +            }
      +            if (is_array($previous_logs)) {
      +                $new_log_entry['order'] = count($previous_logs) + 1;
      +            } else {
      +                $previous_logs = [];
      +                $new_log_entry['order'] = 1;
      +            }
      +        }
      +
      +        $previous_logs[] = $new_log_entry;
      +
      +        try {
      +            $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_THROW_ON_ERROR);
      +        } catch (\JsonException $e) {
      +            $this->application_deployment_queue->logs = json_encode($previous_logs, flags: JSON_INVALID_UTF8_SUBSTITUTE);
      +        }
      +
      +        $this->application_deployment_queue->save();
      +    }
       }
      diff --git a/app/Traits/HasSafeStringAttribute.php b/app/Traits/HasSafeStringAttribute.php
      new file mode 100644
      index 000000000..8a5d2ce77
      --- /dev/null
      +++ b/app/Traits/HasSafeStringAttribute.php
      @@ -0,0 +1,25 @@
      +attributes['name'] = $this->customizeName($sanitized);
      +    }
      +
      +    protected function customizeName($value)
      +    {
      +        return $value; // Default: no customization
      +    }
      +
      +    public function setDescriptionAttribute($value)
      +    {
      +        $this->attributes['description'] = strip_tags($value);
      +    }
      +}
      diff --git a/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php
      new file mode 100644
      index 000000000..a26481056
      --- /dev/null
      +++ b/app/Traits/SshRetryable.php
      @@ -0,0 +1,174 @@
      +getMessage();
      +
      +                // Check if it's retryable and not the last attempt
      +                if ($this->isRetryableSshError($lastErrorMessage) && $attempt < $maxRetries - 1) {
      +                    $delay = $this->calculateRetryDelay($attempt);
      +
      +                    // Track SSH retry event in Sentry
      +                    $this->trackSshRetryEvent($attempt + 1, $maxRetries, $delay, $lastErrorMessage, $context);
      +
      +                    // Add deployment log if available (for ExecuteRemoteCommand trait)
      +                    if (isset($this->application_deployment_queue) && method_exists($this, 'addRetryLogEntry')) {
      +                        $this->addRetryLogEntry($attempt + 1, $maxRetries, $delay, $lastErrorMessage);
      +                    }
      +
      +                    sleep($delay);
      +
      +                    continue;
      +                }
      +
      +                // Not retryable or max retries reached
      +                break;
      +            }
      +        }
      +
      +        // All retries exhausted
      +        if ($attempt >= $maxRetries) {
      +            Log::error('SSH operation failed after all retries', array_merge($context, [
      +                'attempts' => $attempt,
      +                'error' => $lastErrorMessage,
      +            ]));
      +        }
      +
      +        if ($throwError && $lastError) {
      +            // If the error message is empty, provide a more meaningful one
      +            if (empty($lastErrorMessage) || trim($lastErrorMessage) === '') {
      +                $contextInfo = isset($context['server']) ? " to server {$context['server']}" : '';
      +                $attemptInfo = $attempt > 1 ? " after {$attempt} attempts" : '';
      +                throw new \RuntimeException("SSH connection failed{$contextInfo}{$attemptInfo}", $lastError->getCode());
      +            }
      +            throw $lastError;
      +        }
      +
      +        return null;
      +    }
      +
      +    /**
      +     * Track SSH retry event in Sentry
      +     */
      +    protected function trackSshRetryEvent(int $attempt, int $maxRetries, int $delay, string $errorMessage, array $context = []): void
      +    {
      +        // Only track in production/cloud instances
      +        if (isDev() || ! config('constants.sentry.sentry_dsn')) {
      +            return;
      +        }
      +
      +        try {
      +            app('sentry')->captureMessage(
      +                'SSH connection retry triggered',
      +                \Sentry\Severity::warning(),
      +                [
      +                    'extra' => [
      +                        'attempt' => $attempt,
      +                        'max_retries' => $maxRetries,
      +                        'delay_seconds' => $delay,
      +                        'error_message' => $errorMessage,
      +                        'context' => $context,
      +                        'retryable_error' => true,
      +                    ],
      +                    'tags' => [
      +                        'component' => 'ssh_retry',
      +                        'error_type' => 'connection_retry',
      +                    ],
      +                ]
      +            );
      +        } catch (\Throwable $e) {
      +            // Don't let Sentry tracking errors break the SSH retry flow
      +            Log::warning('Failed to track SSH retry event in Sentry', [
      +                'error' => $e->getMessage(),
      +                'original_attempt' => $attempt,
      +            ]);
      +        }
      +    }
      +}
      diff --git a/app/View/Components/Forms/Button.php b/app/View/Components/Forms/Button.php
      index bf88d3f88..b54444261 100644
      --- a/app/View/Components/Forms/Button.php
      +++ b/app/View/Components/Forms/Button.php
      @@ -4,6 +4,7 @@ namespace App\View\Components\Forms;
       
       use Closure;
       use Illuminate\Contracts\View\View;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
       
       class Button extends Component
      @@ -17,7 +18,19 @@ class Button extends Component
               public ?string $modalId = null,
               public string $defaultClass = 'button',
               public bool $showLoadingIndicator = true,
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
           ) {
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +            }
      +        }
      +
               if ($this->noStyle) {
                   $this->defaultClass = '';
               }
      diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php
      index 8db739642..ece7f0e35 100644
      --- a/app/View/Components/Forms/Checkbox.php
      +++ b/app/View/Components/Forms/Checkbox.php
      @@ -4,6 +4,7 @@ namespace App\View\Components\Forms;
       
       use Closure;
       use Illuminate\Contracts\View\View;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
       
       class Checkbox extends Component
      @@ -22,7 +23,20 @@ class Checkbox extends Component
               public string|bool $instantSave = false,
               public bool $disabled = false,
               public string $defaultClass = 'dark:border-neutral-700 text-coolgray-400 focus:ring-warning dark:bg-coolgray-100 rounded-sm cursor-pointer dark:disabled:bg-base dark:disabled:cursor-not-allowed',
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
           ) {
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +                $this->instantSave = false; // Disable instant save for unauthorized users
      +            }
      +        }
      +
               if ($this->disabled) {
                   $this->defaultClass .= ' opacity-40';
               }
      diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php
      index 7283ef20f..83c98c0df 100644
      --- a/app/View/Components/Forms/Input.php
      +++ b/app/View/Components/Forms/Input.php
      @@ -4,6 +4,7 @@ namespace App\View\Components\Forms;
       
       use Closure;
       use Illuminate\Contracts\View\View;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
       use Visus\Cuid2\Cuid2;
       
      @@ -25,7 +26,20 @@ class Input extends Component
               public string $autocomplete = 'off',
               public ?int $minlength = null,
               public ?int $maxlength = null,
      -    ) {}
      +        public bool $autofocus = false,
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
      +    ) {
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +            }
      +        }
      +    }
       
           public function render(): View|Closure|string
           {
      diff --git a/app/View/Components/Forms/Select.php b/app/View/Components/Forms/Select.php
      index feb4bf343..49b69136b 100644
      --- a/app/View/Components/Forms/Select.php
      +++ b/app/View/Components/Forms/Select.php
      @@ -4,6 +4,7 @@ namespace App\View\Components\Forms;
       
       use Closure;
       use Illuminate\Contracts\View\View;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
       use Visus\Cuid2\Cuid2;
       
      @@ -19,9 +20,19 @@ class Select extends Component
               public ?string $helper = null,
               public bool $required = false,
               public bool $disabled = false,
      -        public string $defaultClass = 'select w-full'
      +        public string $defaultClass = 'select w-full',
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
           ) {
      -        //
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +            }
      +        }
           }
       
           /**
      diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php
      index 6081c2a8a..3148d2566 100644
      --- a/app/View/Components/Forms/Textarea.php
      +++ b/app/View/Components/Forms/Textarea.php
      @@ -4,6 +4,7 @@ namespace App\View\Components\Forms;
       
       use Closure;
       use Illuminate\Contracts\View\View;
      +use Illuminate\Support\Facades\Gate;
       use Illuminate\View\Component;
       use Visus\Cuid2\Cuid2;
       
      @@ -33,8 +34,18 @@ class Textarea extends Component
               public string $defaultClassInput = 'input',
               public ?int $minlength = null,
               public ?int $maxlength = null,
      +        public ?string $canGate = null,
      +        public mixed $canResource = null,
      +        public bool $autoDisable = true,
           ) {
      -        //
      +        // Handle authorization-based disabling
      +        if ($this->canGate && $this->canResource && $this->autoDisable) {
      +            $hasPermission = Gate::allows($this->canGate, $this->canResource);
      +
      +            if (! $hasPermission) {
      +                $this->disabled = true;
      +            }
      +        }
           }
       
           /**
      diff --git a/app/View/Components/services/advanced.php b/app/View/Components/Services/Advanced.php
      similarity index 85%
      rename from app/View/Components/services/advanced.php
      rename to app/View/Components/Services/Advanced.php
      index 8104eaad4..99729a262 100644
      --- a/app/View/Components/services/advanced.php
      +++ b/app/View/Components/Services/Advanced.php
      @@ -1,13 +1,13 @@
       
      +- [ ] #1 Docker BuildKit cache mounts are added to Composer dependency installation in production Dockerfile
      +- [ ] #2 Docker BuildKit cache mounts are added to NPM dependency installation in production Dockerfile
      +- [ ] #3 GitHub Actions BuildX setup is configured for both AMD64 and AARCH64 jobs
      +- [ ] #4 Registry cache-from and cache-to configurations are implemented for both architecture builds
      +- [ ] #5 Build time reduction of at least 40% is achieved in staging builds
      +- [ ] #6 GitHub Actions minutes consumption is reduced compared to baseline
      +- [ ] #7 All existing build functionality remains intact with no regressions
      +
      +
      +## Implementation Plan
      +
      +1. Modify docker/production/Dockerfile to add BuildKit cache mounts:
      +   - Add cache mount for Composer dependencies at line 30: --mount=type=cache,target=/var/www/.composer/cache
      +   - Add cache mount for NPM dependencies at line 41: --mount=type=cache,target=/root/.npm
      +
      +2. Update .github/workflows/coolify-staging-build.yml for AMD64 job:
      +   - Add docker/setup-buildx-action@v3 step after checkout
      +   - Configure cache-from and cache-to parameters in build-push-action
      +   - Use registry caching with buildcache-amd64 tags
      +
      +3. Update .github/workflows/coolify-staging-build.yml for AARCH64 job:
      +   - Add docker/setup-buildx-action@v3 step after checkout  
      +   - Configure cache-from and cache-to parameters in build-push-action
      +   - Use registry caching with buildcache-aarch64 tags
      +
      +4. Test implementation:
      +   - Measure baseline build times before changes
      +   - Deploy changes and monitor initial build (will be slower due to cache population)
      +   - Measure subsequent build times to verify 40%+ improvement
      +   - Validate all build outputs and functionality remain unchanged
      +
      +5. Monitor and validate:
      +   - Track GitHub Actions minutes consumption reduction
      +   - Ensure Docker registry storage usage is reasonable
      +   - Verify no build failures or regressions introduced
      diff --git a/backlog/tasks/task-00001.01 - Add-BuildKit-cache-mounts-to-Dockerfile.md b/backlog/tasks/task-00001.01 - Add-BuildKit-cache-mounts-to-Dockerfile.md
      new file mode 100644
      index 000000000..93fa3e431
      --- /dev/null
      +++ b/backlog/tasks/task-00001.01 - Add-BuildKit-cache-mounts-to-Dockerfile.md	
      @@ -0,0 +1,24 @@
      +---
      +id: task-00001.01
      +title: Add BuildKit cache mounts to Dockerfile
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - docker
      +  - buildkit
      +  - performance
      +  - dockerfile
      +dependencies: []
      +parent_task_id: task-00001
      +priority: high
      +---
      +
      +## Description
      +
      +Modify the production Dockerfile to include BuildKit cache mounts for Composer and NPM dependencies to speed up subsequent builds by reusing cached dependency installations
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 Cache mount for Composer dependencies is added at line 30 with --mount=type=cache target=/var/www/.composer/cache,Cache mount for NPM dependencies is added at line 41 with --mount=type=cache target=/root/.npm,Dockerfile syntax remains valid and builds successfully,All existing functionality is preserved with no regressions
      +
      diff --git a/backlog/tasks/task-00001.02 - Configure-BuildX-and-registry-caching-for-AMD64-staging-builds.md b/backlog/tasks/task-00001.02 - Configure-BuildX-and-registry-caching-for-AMD64-staging-builds.md
      new file mode 100644
      index 000000000..60ac514f6
      --- /dev/null
      +++ b/backlog/tasks/task-00001.02 - Configure-BuildX-and-registry-caching-for-AMD64-staging-builds.md	
      @@ -0,0 +1,24 @@
      +---
      +id: task-00001.02
      +title: Configure BuildX and registry caching for AMD64 staging builds
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - github-actions
      +  - buildx
      +  - caching
      +  - amd64
      +dependencies: []
      +parent_task_id: task-00001
      +priority: high
      +---
      +
      +## Description
      +
      +Update the GitHub Actions workflow to add BuildX setup and configure registry-based caching for the AMD64 build job to leverage Docker layer caching across builds
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 docker/setup-buildx-action@v3 step is added after checkout in AMD64 job,Registry cache configuration is added to build-push-action with cache-from and cache-to parameters,Cache tags use buildcache-amd64 naming convention for architecture-specific caching,Build job runs successfully with caching enabled,No impact on existing build outputs or functionality
      +
      diff --git a/backlog/tasks/task-00001.03 - Configure-BuildX-and-registry-caching-for-AARCH64-staging-builds.md b/backlog/tasks/task-00001.03 - Configure-BuildX-and-registry-caching-for-AARCH64-staging-builds.md
      new file mode 100644
      index 000000000..3dd730d34
      --- /dev/null
      +++ b/backlog/tasks/task-00001.03 - Configure-BuildX-and-registry-caching-for-AARCH64-staging-builds.md	
      @@ -0,0 +1,25 @@
      +---
      +id: task-00001.03
      +title: Configure BuildX and registry caching for AARCH64 staging builds
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - github-actions
      +  - buildx
      +  - caching
      +  - aarch64
      +  - self-hosted
      +dependencies: []
      +parent_task_id: task-00001
      +priority: high
      +---
      +
      +## Description
      +
      +Update the GitHub Actions workflow to add BuildX setup and configure registry-based caching for the AARCH64 build job running on self-hosted ARM64 runners
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 docker/setup-buildx-action@v3 step is added after checkout in AARCH64 job,Registry cache configuration is added to build-push-action with cache-from and cache-to parameters,Cache tags use buildcache-aarch64 naming convention for architecture-specific caching,Build job runs successfully on self-hosted ARM64 runner with caching enabled,No impact on existing build outputs or functionality
      +
      diff --git a/backlog/tasks/task-00001.04 - Establish-build-time-baseline-measurements.md b/backlog/tasks/task-00001.04 - Establish-build-time-baseline-measurements.md
      new file mode 100644
      index 000000000..6fa997663
      --- /dev/null
      +++ b/backlog/tasks/task-00001.04 - Establish-build-time-baseline-measurements.md	
      @@ -0,0 +1,24 @@
      +---
      +id: task-00001.04
      +title: Establish build time baseline measurements
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - performance
      +  - testing
      +  - baseline
      +  - measurement
      +dependencies: []
      +parent_task_id: task-00001
      +priority: medium
      +---
      +
      +## Description
      +
      +Measure and document current staging build times for both AMD64 and AARCH64 architectures before implementing caching optimizations to establish a performance baseline for comparison
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 Baseline build times are measured for at least 3 consecutive AMD64 builds,Baseline build times are measured for at least 3 consecutive AARCH64 builds,Average build time and GitHub Actions minutes consumption are documented,Baseline measurements include both cold builds and any existing warm builds,Results are documented in a format suitable for comparing against post-optimization builds
      +
      diff --git a/backlog/tasks/task-00001.05 - Validate-caching-implementation-and-measure-performance-improvements.md b/backlog/tasks/task-00001.05 - Validate-caching-implementation-and-measure-performance-improvements.md
      new file mode 100644
      index 000000000..6a11168da
      --- /dev/null
      +++ b/backlog/tasks/task-00001.05 - Validate-caching-implementation-and-measure-performance-improvements.md	
      @@ -0,0 +1,28 @@
      +---
      +id: task-00001.05
      +title: Validate caching implementation and measure performance improvements
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - testing
      +  - performance
      +  - validation
      +  - measurement
      +dependencies:
      +  - task-00001.01
      +  - task-00001.02
      +  - task-00001.03
      +  - task-00001.04
      +parent_task_id: task-00001
      +priority: high
      +---
      +
      +## Description
      +
      +Test the complete Docker build caching implementation by running multiple staging builds and measuring performance improvements to ensure the 40% build time reduction target is achieved
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 First build after cache implementation runs successfully (expected slower due to cache population),Second and subsequent builds show significant time reduction compared to baseline,Build time reduction of at least 40% is achieved and documented,GitHub Actions minutes consumption is reduced compared to baseline measurements,All Docker images function identically to pre-optimization builds,No build failures or regressions are introduced by caching changes
      +
      diff --git a/backlog/tasks/task-00001.06 - Document-cache-optimization-results-and-create-production-workflow-plan.md b/backlog/tasks/task-00001.06 - Document-cache-optimization-results-and-create-production-workflow-plan.md
      new file mode 100644
      index 000000000..3749e58f3
      --- /dev/null
      +++ b/backlog/tasks/task-00001.06 - Document-cache-optimization-results-and-create-production-workflow-plan.md	
      @@ -0,0 +1,25 @@
      +---
      +id: task-00001.06
      +title: Document cache optimization results and create production workflow plan
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 12:19'
      +labels:
      +  - documentation
      +  - planning
      +  - production
      +  - analysis
      +dependencies:
      +  - task-00001.05
      +parent_task_id: task-00001
      +priority: low
      +---
      +
      +## Description
      +
      +Document the staging build caching results and create a detailed plan for applying the same optimizations to the production build workflow if staging results meet performance targets
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 Performance improvement results are documented with before/after metrics,Cost savings in GitHub Actions minutes are calculated and documented,Analysis of Docker registry storage impact is provided,Detailed plan for production workflow optimization is created,Recommendations for cache retention policies and cleanup strategies are provided,Documentation includes rollback procedures if issues arise
      +
      diff --git a/backlog/tasks/task-00002 - Fix-Docker-cleanup-irregular-scheduling-in-cloud-environment.md b/backlog/tasks/task-00002 - Fix-Docker-cleanup-irregular-scheduling-in-cloud-environment.md
      new file mode 100644
      index 000000000..d0e63456b
      --- /dev/null
      +++ b/backlog/tasks/task-00002 - Fix-Docker-cleanup-irregular-scheduling-in-cloud-environment.md	
      @@ -0,0 +1,82 @@
      +---
      +id: task-00002
      +title: Fix Docker cleanup irregular scheduling in cloud environment
      +status: Done
      +assignee:
      +  - '@claude'
      +created_date: '2025-08-26 12:17'
      +updated_date: '2025-08-26 12:26'
      +labels:
      +  - backend
      +  - performance
      +  - cloud
      +dependencies: []
      +priority: high
      +---
      +
      +## Description
      +
      +Docker cleanup jobs are running at irregular intervals instead of hourly as configured (0 * * * *) in the cloud environment with 2 Horizon workers and thousands of servers. The issue stems from the ServerManagerJob processing servers sequentially with a frozen execution time, causing timing mismatches when evaluating cron expressions for large server counts.
      +
      +## Acceptance Criteria
      +
      +- [x] #1 Docker cleanup runs consistently at the configured hourly intervals
      +- [x] #2 All eligible servers receive cleanup jobs when due
      +- [x] #3 Solution handles thousands of servers efficiently
      +- [x] #4 Maintains backwards compatibility with existing settings
      +- [x] #5 Cloud subscription checks are properly enforced
      +
      +
      +## Implementation Plan
      +
      +1. Add processDockerCleanups() method to ScheduledJobManager
      +   - Implement method to fetch all eligible servers
      +   - Apply frozen execution time for consistent cron evaluation
      +   - Check server functionality and cloud subscription status
      +   - Dispatch DockerCleanupJob for servers where cleanup is due
      +
      +2. Implement helper methods in ScheduledJobManager
      +   - getServersForCleanup(): Fetch servers with proper cloud/self-hosted filtering
      +   - shouldProcessDockerCleanup(): Validate server eligibility
      +   - Reuse existing shouldRunNow() method with frozen execution time
      +
      +3. Remove Docker cleanup logic from ServerManagerJob
      +   - Delete lines 136-150 that handle Docker cleanup scheduling
      +   - Keep other server management tasks intact
      +
      +4. Test the implementation
      +   - Verify hourly execution with test servers
      +   - Check timezone handling
      +   - Validate cloud subscription filtering
      +   - Monitor for duplicate job prevention via WithoutOverlapping middleware
      +
      +5. Deploy strategy
      +   - First deploy updated ScheduledJobManager
      +   - Monitor logs for successful hourly executions
      +   - Once confirmed, remove cleanup from ServerManagerJob
      +   - No database migrations required
      +
      +## Implementation Notes
      +
      +Successfully migrated Docker cleanup scheduling from ServerManagerJob to ScheduledJobManager.
      +
      +**Changes Made:**
      +1. Added processDockerCleanups() method to ScheduledJobManager that processes all servers with a single frozen execution time
      +2. Implemented getServersForCleanup() to fetch servers with proper cloud/self-hosted filtering
      +3. Implemented shouldProcessDockerCleanup() for server eligibility validation
      +4. Removed Docker cleanup logic from ServerManagerJob (lines 136-150)
      +
      +**Key Improvements:**
      +- All servers now evaluated against the same timestamp, ensuring consistent hourly execution
      +- Proper cloud subscription checks maintained
      +- Backwards compatible - no database migrations or settings changes required
      +- Follows the same proven pattern used for database backups
      +
      +**Files Modified:**
      +- app/Jobs/ScheduledJobManager.php: Added Docker cleanup processing
      +- app/Jobs/ServerManagerJob.php: Removed Docker cleanup logic
      +
      +**Testing:**
      +- Syntax validation passed
      +- Code formatting verified with Laravel Pint
      +- PHPStan analysis completed (existing warnings unrelated to changes)
      diff --git a/backlog/tasks/task-00003 - Simplify-resource-operations-UI-replace-boxes-with-dropdown-selections.md b/backlog/tasks/task-00003 - Simplify-resource-operations-UI-replace-boxes-with-dropdown-selections.md
      new file mode 100644
      index 000000000..38aa18209
      --- /dev/null
      +++ b/backlog/tasks/task-00003 - Simplify-resource-operations-UI-replace-boxes-with-dropdown-selections.md	
      @@ -0,0 +1,30 @@
      +---
      +id: task-00003
      +title: Simplify resource operations UI - replace boxes with dropdown selections
      +status: To Do
      +assignee: []
      +created_date: '2025-08-26 13:22'
      +updated_date: '2025-08-26 13:22'
      +labels:
      +  - ui
      +  - frontend
      +  - livewire
      +dependencies: []
      +priority: medium
      +---
      +
      +## Description
      +
      +Replace the current box-based layout in resource-operations.blade.php with clean dropdown selections to improve UX when there are many servers, projects, or environments. The current interface becomes overwhelming and cluttered with multiple modal confirmation boxes for each option.
      +
      +## Acceptance Criteria
      +
      +- [ ] #1 Clone section shows a dropdown to select server/destination instead of multiple boxes
      +- [ ] #2 Move section shows a dropdown to select project/environment instead of multiple boxes
      +- [ ] #3 Single "Clone Resource" button that triggers modal after dropdown selection
      +- [ ] #4 Single "Move Resource" button that triggers modal after dropdown selection
      +- [ ] #5 Authorization warnings remain in place for users without permissions
      +- [ ] #6 All existing functionality preserved (cloning, moving, success messages)
      +- [ ] #7 Clean, simple interface that scales well with many options
      +- [ ] #8 Mobile-friendly dropdown interface
      +
      diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
      index 307c7ed1b..5d0f9a2a7 100644
      --- a/bootstrap/helpers/api.php
      +++ b/bootstrap/helpers/api.php
      @@ -176,4 +176,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
           $request->offsetUnset('private_key_uuid');
           $request->offsetUnset('use_build_server');
           $request->offsetUnset('is_static');
      +    $request->offsetUnset('force_domain_override');
       }
      diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
      index 919b2bde5..db7767c1e 100644
      --- a/bootstrap/helpers/applications.php
      +++ b/bootstrap/helpers/applications.php
      @@ -1,12 +1,15 @@
       id,
               );
      -    } elseif (next_queuable($server_id, $application_id, $commit)) {
      +    } elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) {
               ApplicationDeploymentJob::dispatch(
                   application_deployment_queue_id: $deployment->id,
               );
      @@ -93,32 +96,31 @@ function force_start_deployment(ApplicationDeploymentQueue $deployment)
       function queue_next_deployment(Application $application)
       {
           $server_id = $application->destination->server_id;
      -    $next_found = ApplicationDeploymentQueue::where('server_id', $server_id)->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at')->first();
      -    if ($next_found) {
      -        $next_found->update([
      -            'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
      -        ]);
      +    $queued_deployments = ApplicationDeploymentQueue::where('server_id', $server_id)
      +        ->where('status', ApplicationDeploymentStatus::QUEUED)
      +        ->get()
      +        ->sortBy('created_at');
       
      -        ApplicationDeploymentJob::dispatch(
      -            application_deployment_queue_id: $next_found->id,
      -        );
      +    foreach ($queued_deployments as $next_deployment) {
      +        // Check if this queued deployment can actually run
      +        if (next_queuable($next_deployment->server_id, $next_deployment->application_id, $next_deployment->commit, $next_deployment->pull_request_id)) {
      +            $next_deployment->update([
      +                'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
      +            ]);
      +
      +            ApplicationDeploymentJob::dispatch(
      +                application_deployment_queue_id: $next_deployment->id,
      +            );
      +        }
           }
       }
       
      -function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD'): bool
      +function next_queuable(string $server_id, string $application_id, string $commit = 'HEAD', int $pull_request_id = 0): bool
       {
      -    // Check if there's already a deployment in progress for this application and commit
      -    $existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
      -        ->where('commit', $commit)
      -        ->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
      -        ->first();
      -
      -    if ($existing_deployment) {
      -        return false;
      -    }
      -
      -    // Check if there's any deployment in progress for this application
      +    // Check if there's already a deployment in progress for this application with the same pull_request_id
      +    // This allows normal deployments and PR deployments to run concurrently
           $in_progress = ApplicationDeploymentQueue::where('application_id', $application_id)
      +        ->where('pull_request_id', $pull_request_id)
               ->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
               ->exists();
       
      @@ -142,13 +144,15 @@ function next_queuable(string $server_id, string $application_id, string $commit
       function next_after_cancel(?Server $server = null)
       {
           if ($server) {
      -        $next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))->where('status', ApplicationDeploymentStatus::QUEUED)->get()->sortBy('created_at');
      +        $next_found = ApplicationDeploymentQueue::where('server_id', data_get($server, 'id'))
      +            ->where('status', ApplicationDeploymentStatus::QUEUED)
      +            ->get()
      +            ->sortBy('created_at');
      +
               if ($next_found->count() > 0) {
                   foreach ($next_found as $next) {
      -                $server = Server::find($next->server_id);
      -                $concurrent_builds = $server->settings->concurrent_builds;
      -                $inprogress_deployments = ApplicationDeploymentQueue::where('server_id', $next->server_id)->whereIn('status', [ApplicationDeploymentStatus::QUEUED])->get()->sortByDesc('created_at');
      -                if ($inprogress_deployments->count() < $concurrent_builds) {
      +                // Use next_queuable to properly check if this deployment can run
      +                if (next_queuable($next->server_id, $next->application_id, $next->commit, $next->pull_request_id)) {
                           $next->update([
                               'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
                           ]);
      @@ -157,8 +161,195 @@ function next_after_cancel(?Server $server = null)
                               application_deployment_queue_id: $next->id,
                           );
                       }
      -                break;
                   }
               }
           }
       }
      +
      +function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application
      +{
      +    $uuid = $overrides['uuid'] ?? (string) new Cuid2;
      +    $server = $destination->server;
      +
      +    // Prepare name and URL
      +    $name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid;
      +    $applicationSettings = $source->settings;
      +    $url = $overrides['fqdn'] ?? $source->fqdn;
      +
      +    if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
      +        $url = generateUrl(server: $server, random: $uuid);
      +    }
      +
      +    // Clone the application
      +    $newApplication = $source->replicate([
      +        'id',
      +        'created_at',
      +        'updated_at',
      +        'additional_servers_count',
      +        'additional_networks_count',
      +    ])->fill(array_merge([
      +        'uuid' => $uuid,
      +        'name' => $name,
      +        'fqdn' => $url,
      +        'status' => 'exited',
      +        'destination_id' => $destination->id,
      +    ], $overrides));
      +    $newApplication->save();
      +
      +    // Update custom labels if needed
      +    if ($newApplication->destination->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) {
      +        $customLabels = str(implode('|coolify|', generateLabelsApplication($newApplication)))->replace('|coolify|', "\n");
      +        $newApplication->custom_labels = base64_encode($customLabels);
      +        $newApplication->save();
      +    }
      +
      +    // Clone settings
      +    $newApplication->settings()->delete();
      +    if ($applicationSettings) {
      +        $newApplicationSettings = $applicationSettings->replicate([
      +            'id',
      +            'created_at',
      +            'updated_at',
      +        ])->fill([
      +            'application_id' => $newApplication->id,
      +        ]);
      +        $newApplicationSettings->save();
      +    }
      +
      +    // Clone tags
      +    $tags = $source->tags;
      +    foreach ($tags as $tag) {
      +        $newApplication->tags()->attach($tag->id);
      +    }
      +
      +    // Clone scheduled tasks
      +    $scheduledTasks = $source->scheduled_tasks()->get();
      +    foreach ($scheduledTasks as $task) {
      +        $newTask = $task->replicate([
      +            'id',
      +            'created_at',
      +            'updated_at',
      +        ])->fill([
      +            'uuid' => (string) new Cuid2,
      +            'application_id' => $newApplication->id,
      +            'team_id' => currentTeam()->id,
      +        ]);
      +        $newTask->save();
      +    }
      +
      +    // Clone previews with FQDN regeneration
      +    $applicationPreviews = $source->previews()->get();
      +    foreach ($applicationPreviews as $preview) {
      +        $newPreview = $preview->replicate([
      +            'id',
      +            'created_at',
      +            'updated_at',
      +        ])->fill([
      +            'uuid' => (string) new Cuid2,
      +            'application_id' => $newApplication->id,
      +            'status' => 'exited',
      +            'fqdn' => null,
      +            'docker_compose_domains' => null,
      +        ]);
      +        $newPreview->save();
      +
      +        // Regenerate FQDN for the cloned preview
      +        if ($newApplication->build_pack === 'dockercompose') {
      +            $newPreview->generate_preview_fqdn_compose();
      +        } else {
      +            $newPreview->generate_preview_fqdn();
      +        }
      +    }
      +
      +    // Clone persistent volumes
      +    $persistentVolumes = $source->persistentStorages()->get();
      +    foreach ($persistentVolumes as $volume) {
      +        $newName = '';
      +        if (str_starts_with($volume->name, $source->uuid)) {
      +            $newName = str($volume->name)->replace($source->uuid, $newApplication->uuid);
      +        } else {
      +            $newName = $newApplication->uuid.'-'.str($volume->name)->afterLast('-');
      +        }
      +
      +        $newPersistentVolume = $volume->replicate([
      +            'id',
      +            'created_at',
      +            'updated_at',
      +        ])->fill([
      +            'name' => $newName,
      +            'resource_id' => $newApplication->id,
      +        ]);
      +        $newPersistentVolume->save();
      +
      +        if ($cloneVolumeData) {
      +            try {
      +                StopApplication::dispatch($source, false, false);
      +                $sourceVolume = $volume->name;
      +                $targetVolume = $newPersistentVolume->name;
      +                $sourceServer = $source->destination->server;
      +                $targetServer = $newApplication->destination->server;
      +
      +                VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
      +
      +                queue_application_deployment(
      +                    deployment_uuid: (string) new Cuid2,
      +                    application: $source,
      +                    server: $sourceServer,
      +                    destination: $source->destination,
      +                    no_questions_asked: true
      +                );
      +            } catch (\Exception $e) {
      +                \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
      +            }
      +        }
      +    }
      +
      +    // Clone file storages
      +    $fileStorages = $source->fileStorages()->get();
      +    foreach ($fileStorages as $storage) {
      +        $newStorage = $storage->replicate([
      +            'id',
      +            'created_at',
      +            'updated_at',
      +        ])->fill([
      +            'resource_id' => $newApplication->id,
      +        ]);
      +        $newStorage->save();
      +    }
      +
      +    // Clone production environment variables without triggering the created hook
      +    $environmentVariables = $source->environment_variables()->get();
      +    foreach ($environmentVariables as $environmentVariable) {
      +        \App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) {
      +            $newEnvironmentVariable = $environmentVariable->replicate([
      +                'id',
      +                'created_at',
      +                'updated_at',
      +            ])->fill([
      +                'resourceable_id' => $newApplication->id,
      +                'resourceable_type' => $newApplication->getMorphClass(),
      +                'is_preview' => false,
      +            ]);
      +            $newEnvironmentVariable->save();
      +        });
      +    }
      +
      +    // Clone preview environment variables
      +    $previewEnvironmentVariables = $source->environment_variables_preview()->get();
      +    foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
      +        \App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) {
      +            $newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([
      +                'id',
      +                'created_at',
      +                'updated_at',
      +            ])->fill([
      +                'resourceable_id' => $newApplication->id,
      +                'resourceable_type' => $newApplication->getMorphClass(),
      +                'is_preview' => true,
      +            ]);
      +            $newPreviewEnvironmentVariable->save();
      +        });
      +    }
      +
      +    return $newApplication;
      +}
      diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php
      index 48962f89c..5dbd46b5e 100644
      --- a/bootstrap/helpers/databases.php
      +++ b/bootstrap/helpers/databases.php
      @@ -237,11 +237,18 @@ function removeOldBackups($backup): void
       {
           try {
               if ($backup->executions) {
      -            $localBackupsToDelete = deleteOldBackupsLocally($backup);
      -            if ($localBackupsToDelete->isNotEmpty()) {
      +            // If local backup is disabled, mark all executions as having local storage deleted
      +            if ($backup->disable_local_backup && $backup->save_s3) {
                       $backup->executions()
      -                    ->whereIn('id', $localBackupsToDelete->pluck('id'))
      +                    ->where('local_storage_deleted', false)
                           ->update(['local_storage_deleted' => true]);
      +            } else {
      +                $localBackupsToDelete = deleteOldBackupsLocally($backup);
      +                if ($localBackupsToDelete->isNotEmpty()) {
      +                    $backup->executions()
      +                        ->whereIn('id', $localBackupsToDelete->pluck('id'))
      +                        ->update(['local_storage_deleted' => true]);
      +                }
                   }
               }
       
      @@ -254,10 +261,18 @@ function removeOldBackups($backup): void
                   }
               }
       
      -        $backup->executions()
      -            ->where('local_storage_deleted', true)
      -            ->where('s3_storage_deleted', true)
      -            ->delete();
      +        // Delete executions where both local and S3 storage are marked as deleted
      +        // or where only S3 is enabled and S3 storage is deleted
      +        if ($backup->disable_local_backup && $backup->save_s3) {
      +            $backup->executions()
      +                ->where('s3_storage_deleted', true)
      +                ->delete();
      +        } else {
      +            $backup->executions()
      +                ->where('local_storage_deleted', true)
      +                ->where('s3_storage_deleted', true)
      +                ->delete();
      +        }
       
           } catch (\Exception $e) {
               throw $e;
      diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
      index 944c51e3c..1491e4712 100644
      --- a/bootstrap/helpers/docker.php
      +++ b/bootstrap/helpers/docker.php
      @@ -256,12 +256,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
       
                   if (str($MINIO_BROWSER_REDIRECT_URL->value ?? '')->isEmpty()) {
                       $MINIO_BROWSER_REDIRECT_URL->update([
      -                    'value' => generateFqdn($server, 'console-'.$uuid, true),
      +                    'value' => generateUrl(server: $server, random: 'console-'.$uuid, forceHttps: true),
                       ]);
                   }
                   if (str($MINIO_SERVER_URL->value ?? '')->isEmpty()) {
                       $MINIO_SERVER_URL->update([
      -                    'value' => generateFqdn($server, 'minio-'.$uuid, true),
      +                    'value' => generateUrl(server: $server, random: 'minio-'.$uuid, forceHttps: true),
                       ]);
                   }
                   $payload = collect([
      @@ -279,12 +279,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
       
                   if (str($LOGTO_ENDPOINT->value ?? '')->isEmpty()) {
                       $LOGTO_ENDPOINT->update([
      -                    'value' => generateFqdn($server, 'logto-'.$uuid),
      +                    'value' => generateUrl(server: $server, random: 'logto-'.$uuid),
                       ]);
                   }
                   if (str($LOGTO_ADMIN_ENDPOINT->value ?? '')->isEmpty()) {
                       $LOGTO_ADMIN_ENDPOINT->update([
      -                    'value' => generateFqdn($server, 'logto-admin-'.$uuid),
      +                    'value' => generateUrl(server: $server, random: 'logto-admin-'.$uuid),
                       ]);
                   }
                   $payload = collect([
      @@ -1093,19 +1093,18 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100
       {
           if ($server->isSwarm()) {
               $output = instant_remote_process([
      -            "docker service logs -n {$lines} {$container_id}",
      +            "docker service logs -n {$lines} {$container_id} 2>&1",
               ], $server);
           } else {
               $output = instant_remote_process([
      -            "docker logs -n {$lines} {$container_id}",
      +            "docker logs -n {$lines} {$container_id} 2>&1",
               ], $server);
           }
       
      -    $output .= removeAnsiColors($output);
      +    $output = removeAnsiColors($output);
       
           return $output;
       }
      -
       function escapeEnvVariables($value)
       {
           $search = ['\\', "\r", "\t", "\x0", '"', "'"];
      diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php
      new file mode 100644
      index 000000000..5b665890c
      --- /dev/null
      +++ b/bootstrap/helpers/domains.php
      @@ -0,0 +1,237 @@
      +team();
      +    }
      +
      +    if ($resource) {
      +        if ($resource->getMorphClass() === Application::class && $resource->build_pack === 'dockercompose') {
      +            $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain');
      +            $domains = collect($domains);
      +        } else {
      +            $domains = collect($resource->fqdns);
      +        }
      +    } elseif ($domain) {
      +        $domains = collect([$domain]);
      +    } else {
      +        return ['conflicts' => [], 'hasConflicts' => false];
      +    }
      +
      +    $domains = $domains->map(function ($domain) {
      +        if (str($domain)->endsWith('/')) {
      +            $domain = str($domain)->beforeLast('/');
      +        }
      +
      +        return str($domain);
      +    });
      +
      +    // Filter applications by team if we have a current team
      +    $appsQuery = Application::query();
      +    if ($currentTeam) {
      +        $appsQuery = $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) {
      +            $query->where('team_id', $currentTeam->id);
      +        });
      +    }
      +    $apps = $appsQuery->get();
      +    foreach ($apps as $app) {
      +        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      +        foreach ($list_of_domains as $domain) {
      +            if (str($domain)->endsWith('/')) {
      +                $domain = str($domain)->beforeLast('/');
      +            }
      +            $naked_domain = str($domain)->value();
      +            if ($domains->contains($naked_domain)) {
      +                if (data_get($resource, 'uuid')) {
      +                    if ($resource->uuid !== $app->uuid) {
      +                        $conflicts[] = [
      +                            'domain' => $naked_domain,
      +                            'resource_name' => $app->name,
      +                            'resource_link' => $app->link(),
      +                            'resource_type' => 'application',
      +                            'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
      +                        ];
      +                    }
      +                } elseif ($domain) {
      +                    $conflicts[] = [
      +                        'domain' => $naked_domain,
      +                        'resource_name' => $app->name,
      +                        'resource_link' => $app->link(),
      +                        'resource_type' => 'application',
      +                        'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
      +                    ];
      +                }
      +            }
      +        }
      +    }
      +
      +    // Filter service applications by team if we have a current team
      +    $serviceAppsQuery = ServiceApplication::query();
      +    if ($currentTeam) {
      +        $serviceAppsQuery = $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) {
      +            $query->where('team_id', $currentTeam->id);
      +        });
      +    }
      +    $apps = $serviceAppsQuery->get();
      +    foreach ($apps as $app) {
      +        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      +        foreach ($list_of_domains as $domain) {
      +            if (str($domain)->endsWith('/')) {
      +                $domain = str($domain)->beforeLast('/');
      +            }
      +            $naked_domain = str($domain)->value();
      +            if ($domains->contains($naked_domain)) {
      +                if (data_get($resource, 'uuid')) {
      +                    if ($resource->uuid !== $app->uuid) {
      +                        $conflicts[] = [
      +                            'domain' => $naked_domain,
      +                            'resource_name' => $app->service->name,
      +                            'resource_link' => $app->service->link(),
      +                            'resource_type' => 'service',
      +                            'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
      +                        ];
      +                    }
      +                } elseif ($domain) {
      +                    $conflicts[] = [
      +                        'domain' => $naked_domain,
      +                        'resource_name' => $app->service->name,
      +                        'resource_link' => $app->service->link(),
      +                        'resource_type' => 'service',
      +                        'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
      +                    ];
      +                }
      +            }
      +        }
      +    }
      +
      +    if ($resource) {
      +        $settings = instanceSettings();
      +        if (data_get($settings, 'fqdn')) {
      +            $domain = data_get($settings, 'fqdn');
      +            if (str($domain)->endsWith('/')) {
      +                $domain = str($domain)->beforeLast('/');
      +            }
      +            $naked_domain = str($domain)->value();
      +            if ($domains->contains($naked_domain)) {
      +                $conflicts[] = [
      +                    'domain' => $naked_domain,
      +                    'resource_name' => 'Coolify Instance',
      +                    'resource_link' => '#',
      +                    'resource_type' => 'instance',
      +                    'message' => "Domain $naked_domain is already in use by this Coolify instance",
      +                ];
      +            }
      +        }
      +    }
      +
      +    return [
      +        'conflicts' => $conflicts,
      +        'hasConflicts' => count($conflicts) > 0,
      +    ];
      +}
      +
      +function checkIfDomainIsAlreadyUsedViaAPI(Collection|array $domains, ?string $teamId = null, ?string $uuid = null)
      +{
      +    $conflicts = [];
      +
      +    if (is_null($teamId)) {
      +        return ['error' => 'Team ID is required.'];
      +    }
      +    if (is_array($domains)) {
      +        $domains = collect($domains);
      +    }
      +
      +    $domains = $domains->map(function ($domain) {
      +        if (str($domain)->endsWith('/')) {
      +            $domain = str($domain)->beforeLast('/');
      +        }
      +
      +        return str($domain);
      +    });
      +
      +    // Check applications within the same team
      +    $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid', 'name', 'id']);
      +    $serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->with('service:id,name')->get(['fqdn', 'uuid', 'id', 'service_id']);
      +
      +    if ($uuid) {
      +        $applications = $applications->filter(fn ($app) => $app->uuid !== $uuid);
      +        $serviceApplications = $serviceApplications->filter(fn ($app) => $app->uuid !== $uuid);
      +    }
      +
      +    foreach ($applications as $app) {
      +        if (is_null($app->fqdn)) {
      +            continue;
      +        }
      +        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      +        foreach ($list_of_domains as $domain) {
      +            if (str($domain)->endsWith('/')) {
      +                $domain = str($domain)->beforeLast('/');
      +            }
      +            $naked_domain = str($domain)->value();
      +            if ($domains->contains($naked_domain)) {
      +                $conflicts[] = [
      +                    'domain' => $naked_domain,
      +                    'resource_name' => $app->name,
      +                    'resource_uuid' => $app->uuid,
      +                    'resource_type' => 'application',
      +                    'message' => "Domain $naked_domain is already in use by application '{$app->name}'",
      +                ];
      +            }
      +        }
      +    }
      +
      +    foreach ($serviceApplications as $app) {
      +        if (str($app->fqdn)->isEmpty()) {
      +            continue;
      +        }
      +        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      +        foreach ($list_of_domains as $domain) {
      +            if (str($domain)->endsWith('/')) {
      +                $domain = str($domain)->beforeLast('/');
      +            }
      +            $naked_domain = str($domain)->value();
      +            if ($domains->contains($naked_domain)) {
      +                $conflicts[] = [
      +                    'domain' => $naked_domain,
      +                    'resource_name' => $app->service->name ?? 'Unknown Service',
      +                    'resource_uuid' => $app->uuid,
      +                    'resource_type' => 'service',
      +                    'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'",
      +                ];
      +            }
      +        }
      +    }
      +
      +    // Check instance-level domain
      +    $settings = instanceSettings();
      +    if (data_get($settings, 'fqdn')) {
      +        $domain = data_get($settings, 'fqdn');
      +        if (str($domain)->endsWith('/')) {
      +            $domain = str($domain)->beforeLast('/');
      +        }
      +        $naked_domain = str($domain)->value();
      +        if ($domains->contains($naked_domain)) {
      +            $conflicts[] = [
      +                'domain' => $naked_domain,
      +                'resource_name' => 'Coolify Instance',
      +                'resource_uuid' => null,
      +                'resource_type' => 'instance',
      +                'message' => "Domain $naked_domain is already in use by this Coolify instance",
      +            ];
      +        }
      +    }
      +
      +    return [
      +        'conflicts' => $conflicts,
      +        'hasConflicts' => count($conflicts) > 0,
      +    ];
      +}
      diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
      new file mode 100644
      index 000000000..d4701d251
      --- /dev/null
      +++ b/bootstrap/helpers/parsers.php
      @@ -0,0 +1,1990 @@
      + 0 && $remaining[0] === ':') {
      +                $target = substr($remaining, 1);
      +            } else {
      +                $target = $remaining;
      +            }
      +        } else {
      +            $parts = explode(':', $volumeString);
      +            $source = $parts[0];
      +            $target = $parts[1];
      +        }
      +    } elseif ($colonCount === 2) {
      +        // Volume with mode OR Windows path OR env var with mode
      +        // Handle env var with mode first
      +        if ($hasEnvVarWithDefault) {
      +            // ${VAR:-default}:/path:mode
      +            $source = substr($volumeString, 0, $envVarEndPos);
      +            $remaining = substr($volumeString, $envVarEndPos);
      +
      +            if (strlen($remaining) > 0 && $remaining[0] === ':') {
      +                $remaining = substr($remaining, 1);
      +                $lastColon = strrpos($remaining, ':');
      +
      +                if ($lastColon !== false) {
      +                    $possibleMode = substr($remaining, $lastColon + 1);
      +                    $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
      +
      +                    if (in_array($possibleMode, $validModes)) {
      +                        $mode = $possibleMode;
      +                        $target = substr($remaining, 0, $lastColon);
      +                    } else {
      +                        $target = $remaining;
      +                    }
      +                } else {
      +                    $target = $remaining;
      +                }
      +            }
      +        } elseif (preg_match('/^[A-Za-z]:/', $volumeString)) {
      +            // Windows path as source (C:/, D:/, etc.)
      +            // Find the second colon which is the real separator
      +            $secondColon = strpos($volumeString, ':', 2);
      +            if ($secondColon !== false) {
      +                $source = substr($volumeString, 0, $secondColon);
      +                $target = substr($volumeString, $secondColon + 1);
      +            } else {
      +                // Malformed, treat as is
      +                $source = $volumeString;
      +                $target = $volumeString;
      +            }
      +        } else {
      +            // Not a Windows path, check for mode
      +            $lastColon = strrpos($volumeString, ':');
      +            $possibleMode = substr($volumeString, $lastColon + 1);
      +
      +            // Check if the last part is a valid Docker volume mode
      +            $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
      +
      +            if (in_array($possibleMode, $validModes)) {
      +                // It's a mode
      +                // Examples: "gitea:/data:ro" or "./data:/app/data:rw"
      +                $mode = $possibleMode;
      +                $volumeWithoutMode = substr($volumeString, 0, $lastColon);
      +                $colonPos = strpos($volumeWithoutMode, ':');
      +
      +                if ($colonPos !== false) {
      +                    $source = substr($volumeWithoutMode, 0, $colonPos);
      +                    $target = substr($volumeWithoutMode, $colonPos + 1);
      +                } else {
      +                    // Shouldn't happen for valid volume strings
      +                    $source = $volumeWithoutMode;
      +                    $target = $volumeWithoutMode;
      +                }
      +            } else {
      +                // The last colon is part of the path
      +                // For now, treat the first occurrence of : as the separator
      +                $firstColon = strpos($volumeString, ':');
      +                $source = substr($volumeString, 0, $firstColon);
      +                $target = substr($volumeString, $firstColon + 1);
      +            }
      +        }
      +    } else {
      +        // More than 2 colons - likely Windows paths or complex cases
      +        // Use a heuristic: find the most likely separator colon
      +        // Look for patterns like "C:" at the beginning (Windows drive)
      +        if (preg_match('/^[A-Za-z]:/', $volumeString)) {
      +            // Windows path as source
      +            // Find the next colon after the drive letter
      +            $secondColon = strpos($volumeString, ':', 2);
      +            if ($secondColon !== false) {
      +                $source = substr($volumeString, 0, $secondColon);
      +                $remaining = substr($volumeString, $secondColon + 1);
      +
      +                // Check if there's a mode at the end
      +                $lastColon = strrpos($remaining, ':');
      +                if ($lastColon !== false) {
      +                    $possibleMode = substr($remaining, $lastColon + 1);
      +                    $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
      +
      +                    if (in_array($possibleMode, $validModes)) {
      +                        $mode = $possibleMode;
      +                        $target = substr($remaining, 0, $lastColon);
      +                    } else {
      +                        $target = $remaining;
      +                    }
      +                } else {
      +                    $target = $remaining;
      +                }
      +            } else {
      +                // Malformed, treat as is
      +                $source = $volumeString;
      +                $target = $volumeString;
      +            }
      +        } else {
      +            // Try to parse normally, treating first : as separator
      +            $firstColon = strpos($volumeString, ':');
      +            $source = substr($volumeString, 0, $firstColon);
      +            $remaining = substr($volumeString, $firstColon + 1);
      +
      +            // Check for mode at the end
      +            $lastColon = strrpos($remaining, ':');
      +            if ($lastColon !== false) {
      +                $possibleMode = substr($remaining, $lastColon + 1);
      +                $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
      +
      +                if (in_array($possibleMode, $validModes)) {
      +                    $mode = $possibleMode;
      +                    $target = substr($remaining, 0, $lastColon);
      +                } else {
      +                    $target = $remaining;
      +                }
      +            } else {
      +                $target = $remaining;
      +            }
      +        }
      +    }
      +
      +    // Handle environment variable expansion in source
      +    // Example: ${VOLUME_DB_PATH:-db} should extract default value if present
      +    if ($source && preg_match('/^\$\{([^}]+)\}$/', $source, $matches)) {
      +        $varContent = $matches[1];
      +
      +        // Check if there's a default value with :-
      +        if (strpos($varContent, ':-') !== false) {
      +            $parts = explode(':-', $varContent, 2);
      +            $varName = $parts[0];
      +            $defaultValue = isset($parts[1]) ? $parts[1] : '';
      +
      +            // If there's a non-empty default value, use it for source
      +            if ($defaultValue !== '') {
      +                $source = $defaultValue;
      +            } else {
      +                // Empty default value, keep the variable reference for env resolution
      +                $source = '${'.$varName.'}';
      +            }
      +        }
      +        // Otherwise keep the variable as-is for later expansion (no default value)
      +    }
      +
      +    return [
      +        'source' => $source !== null ? str($source) : null,
      +        'target' => $target !== null ? str($target) : null,
      +        'mode' => $mode !== null ? str($mode) : null,
      +    ];
      +}
      +
      +function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection
      +{
      +    $uuid = data_get($resource, 'uuid');
      +    $compose = data_get($resource, 'docker_compose_raw');
      +    if (! $compose) {
      +        return collect([]);
      +    }
      +
      +    $pullRequestId = $pull_request_id;
      +    $isPullRequest = $pullRequestId == 0 ? false : true;
      +    $server = data_get($resource, 'destination.server');
      +    $fileStorages = $resource->fileStorages();
      +
      +    try {
      +        $yaml = Yaml::parse($compose);
      +    } catch (\Exception) {
      +        return collect([]);
      +    }
      +    $services = data_get($yaml, 'services', collect([]));
      +    $topLevel = collect([
      +        'volumes' => collect(data_get($yaml, 'volumes', [])),
      +        'networks' => collect(data_get($yaml, 'networks', [])),
      +        'configs' => collect(data_get($yaml, 'configs', [])),
      +        'secrets' => collect(data_get($yaml, 'secrets', [])),
      +    ]);
      +    // If there are predefined volumes, make sure they are not null
      +    if ($topLevel->get('volumes')->count() > 0) {
      +        $temp = collect([]);
      +        foreach ($topLevel['volumes'] as $volumeName => $volume) {
      +            if (is_null($volume)) {
      +                continue;
      +            }
      +            $temp->put($volumeName, $volume);
      +        }
      +        $topLevel['volumes'] = $temp;
      +    }
      +    // Get the base docker network
      +    $baseNetwork = collect([$uuid]);
      +    if ($isPullRequest) {
      +        $baseNetwork = collect(["{$uuid}-{$pullRequestId}"]);
      +    }
      +
      +    $parsedServices = collect([]);
      +
      +    $allMagicEnvironments = collect([]);
      +    foreach ($services as $serviceName => $service) {
      +        $magicEnvironments = collect([]);
      +        $image = data_get_str($service, 'image');
      +        $environment = collect(data_get($service, 'environment', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +
      +        $environment = collect(data_get($service, 'environment', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +
      +        // convert environment variables to one format
      +        $environment = convertToKeyValueCollection($environment);
      +
      +        // Add Coolify defined environments
      +        $allEnvironments = $resource->environment_variables()->get(['key', 'value']);
      +
      +        $allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
      +            return [$item['key'] => $item['value']];
      +        });
      +        // filter and add magic environments
      +        foreach ($environment as $key => $value) {
      +            // Get all SERVICE_ variables from keys and values
      +            $key = str($key);
      +            $value = str($value);
      +            $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
      +            preg_match_all($regex, $value, $valueMatches);
      +            if (count($valueMatches[1]) > 0) {
      +                foreach ($valueMatches[1] as $match) {
      +                    $match = replaceVariables($match);
      +                    if ($match->startsWith('SERVICE_')) {
      +                        if ($magicEnvironments->has($match->value())) {
      +                            continue;
      +                        }
      +                        $magicEnvironments->put($match->value(), '');
      +                    }
      +                }
      +            }
      +            // Get magic environments where we need to preset the FQDN
      +            // for example SERVICE_FQDN_APP_3000 (without a value)
      +            if ($key->startsWith('SERVICE_FQDN_')) {
      +                // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
      +                if (substr_count(str($key)->value(), '_') === 3) {
      +                    $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
      +                    $port = $key->afterLast('_')->value();
      +                } else {
      +                    $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
      +                    $port = null;
      +                }
      +                $fqdn = $resource->fqdn;
      +                if (blank($resource->fqdn)) {
      +                    $fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
      +                }
      +
      +                if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
      +                    $path = $value->value();
      +                    if ($path !== '/') {
      +                        $fqdn = "$fqdn$path";
      +                    }
      +                }
      +                $fqdnWithPort = $fqdn;
      +                if ($port) {
      +                    $fqdnWithPort = "$fqdn:$port";
      +                }
      +                if (is_null($resource->fqdn)) {
      +                    data_forget($resource, 'environment_variables');
      +                    data_forget($resource, 'environment_variables_preview');
      +                    $resource->fqdn = $fqdnWithPort;
      +                    $resource->save();
      +                }
      +
      +                if (substr_count(str($key)->value(), '_') === 2) {
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +                if (substr_count(str($key)->value(), '_') === 3) {
      +
      +                    $newKey = str($key)->beforeLast('_');
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $newKey->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +            }
      +        }
      +
      +        $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
      +        if ($magicEnvironments->count() > 0) {
      +            // Generate Coolify environment variables
      +            foreach ($magicEnvironments as $key => $value) {
      +                $key = str($key);
      +                $value = replaceVariables($value);
      +                $command = parseCommandFromMagicEnvVariable($key);
      +                if ($command->value() === 'FQDN') {
      +                    $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
      +                    $originalFqdnFor = str($fqdnFor)->replace('_', '-');
      +                    if (str($fqdnFor)->contains('-')) {
      +                        $fqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_');
      +                    }
      +                    // Generated FQDN & URL
      +                    $fqdn = generateFqdn(server: $server, random: "$originalFqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
      +                    $url = generateUrl(server: $server, random: "$originalFqdnFor-$uuid");
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                    if ($resource->build_pack === 'dockercompose') {
      +                        $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
      +                        $domainExists = data_get($domains->get($fqdnFor), 'domain');
      +                        $envExists = $resource->environment_variables()->where('key', $key->value())->first();
      +                        if (str($domainExists)->replace('http://', '')->replace('https://', '')->value() !== $envExists->value) {
      +                            $envExists->update([
      +                                'value' => $url,
      +                            ]);
      +                        }
      +                        if (is_null($domainExists)) {
      +                            // Put URL in the domains array instead of FQDN
      +                            $domains->put((string) $fqdnFor, [
      +                                'domain' => $url,
      +                            ]);
      +                            $resource->docker_compose_domains = $domains->toJson();
      +                            $resource->save();
      +                        }
      +                    }
      +                } elseif ($command->value() === 'URL') {
      +                    $urlFor = $key->after('SERVICE_URL_')->lower()->value();
      +                    $originalUrlFor = str($urlFor)->replace('_', '-');
      +                    if (str($urlFor)->contains('-')) {
      +                        $urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
      +                    }
      +                    $url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $url,
      +                        'is_preview' => false,
      +                    ]);
      +                    if ($resource->build_pack === 'dockercompose') {
      +                        $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
      +                        $domainExists = data_get($domains->get($urlFor), 'domain');
      +                        $envExists = $resource->environment_variables()->where('key', $key->value())->first();
      +                        if ($domainExists !== $envExists->value) {
      +                            $envExists->update([
      +                                'value' => $url,
      +                            ]);
      +                        }
      +                        if (is_null($domainExists)) {
      +                            $domains->put((string) $urlFor, [
      +                                'domain' => $url,
      +                            ]);
      +                            $resource->docker_compose_domains = $domains->toJson();
      +                            $resource->save();
      +                        }
      +                    }
      +                } else {
      +                    $value = generateEnvValue($command, $resource);
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $value,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +            }
      +        }
      +    }
      +
      +    // generate SERVICE_NAME variables for docker compose services
      +    $serviceNameEnvironments = collect([]);
      +    if ($resource->build_pack === 'dockercompose') {
      +        $serviceNameEnvironments = generateDockerComposeServiceName($services, $pullRequestId);
      +    }
      +
      +    // Parse the rest of the services
      +    foreach ($services as $serviceName => $service) {
      +        $image = data_get_str($service, 'image');
      +        $restart = data_get_str($service, 'restart', RESTART_MODE);
      +        $logging = data_get($service, 'logging');
      +
      +        if ($server->isLogDrainEnabled()) {
      +            if ($resource->isLogDrainEnabled()) {
      +                $logging = generate_fluentd_configuration();
      +            }
      +        }
      +        $volumes = collect(data_get($service, 'volumes', []));
      +        $networks = collect(data_get($service, 'networks', []));
      +        $use_network_mode = data_get($service, 'network_mode') !== null;
      +        $depends_on = collect(data_get($service, 'depends_on', []));
      +        $labels = collect(data_get($service, 'labels', []));
      +        if ($labels->count() > 0) {
      +            if (isAssociativeArray($labels)) {
      +                $newLabels = collect([]);
      +                $labels->each(function ($value, $key) use ($newLabels) {
      +                    $newLabels->push("$key=$value");
      +                });
      +                $labels = $newLabels;
      +            }
      +        }
      +        $environment = collect(data_get($service, 'environment', []));
      +        $ports = collect(data_get($service, 'ports', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +
      +        $environment = convertToKeyValueCollection($environment);
      +        $coolifyEnvironments = collect([]);
      +
      +        $isDatabase = isDatabaseImage($image, $service);
      +        $volumesParsed = collect([]);
      +
      +        $baseName = generateApplicationContainerName(
      +            application: $resource,
      +            pull_request_id: $pullRequestId
      +        );
      +        $containerName = "$serviceName-$baseName";
      +        $predefinedPort = null;
      +
      +        $originalResource = $resource;
      +
      +        if ($volumes->count() > 0) {
      +            foreach ($volumes as $index => $volume) {
      +                $type = null;
      +                $source = null;
      +                $target = null;
      +                $content = null;
      +                $isDirectory = false;
      +                if (is_string($volume)) {
      +                    $parsed = parseDockerVolumeString($volume);
      +                    $source = $parsed['source'];
      +                    $target = $parsed['target'];
      +                    // Mode is available in $parsed['mode'] if needed
      +                    $foundConfig = $fileStorages->whereMountPath($target)->first();
      +                    if (sourceIsLocal($source)) {
      +                        $type = str('bind');
      +                        if ($foundConfig) {
      +                            $contentNotNull_temp = data_get($foundConfig, 'content');
      +                            if ($contentNotNull_temp) {
      +                                $content = $contentNotNull_temp;
      +                            }
      +                            $isDirectory = data_get($foundConfig, 'is_directory');
      +                        } else {
      +                            // By default, we cannot determine if the bind is a directory or not, so we set it to directory
      +                            $isDirectory = true;
      +                        }
      +                    } else {
      +                        $type = str('volume');
      +                    }
      +                } elseif (is_array($volume)) {
      +                    $type = data_get_str($volume, 'type');
      +                    $source = data_get_str($volume, 'source');
      +                    $target = data_get_str($volume, 'target');
      +                    $content = data_get($volume, 'content');
      +                    $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
      +
      +                    $foundConfig = $fileStorages->whereMountPath($target)->first();
      +                    if ($foundConfig) {
      +                        $contentNotNull_temp = data_get($foundConfig, 'content');
      +                        if ($contentNotNull_temp) {
      +                            $content = $contentNotNull_temp;
      +                        }
      +                        $isDirectory = data_get($foundConfig, 'is_directory');
      +                    } else {
      +                        // if isDirectory is not set (or false) & content is also not set, we assume it is a directory
      +                        if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
      +                            $isDirectory = true;
      +                        }
      +                    }
      +                }
      +                if ($type->value() === 'bind') {
      +                    if ($source->value() === '/var/run/docker.sock') {
      +                        $volume = $source->value().':'.$target->value();
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
      +                        $volume = $source->value().':'.$target->value();
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } else {
      +                        if ((int) $resource->compose_parsing_version >= 4) {
      +                            $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
      +                        } else {
      +                            $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
      +                        }
      +                        $source = replaceLocalSource($source, $mainDirectory);
      +                        if ($isPullRequest) {
      +                            $source = addPreviewDeploymentSuffix($source, $pull_request_id);
      +                        }
      +                        LocalFileVolume::updateOrCreate(
      +                            [
      +                                'mount_path' => $target,
      +                                'resource_id' => $originalResource->id,
      +                                'resource_type' => get_class($originalResource),
      +                            ],
      +                            [
      +                                'fs_path' => $source,
      +                                'mount_path' => $target,
      +                                'content' => $content,
      +                                'is_directory' => $isDirectory,
      +                                'resource_id' => $originalResource->id,
      +                                'resource_type' => get_class($originalResource),
      +                            ]
      +                        );
      +                        if (isDev()) {
      +                            if ((int) $resource->compose_parsing_version >= 4) {
      +                                $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
      +                            } else {
      +                                $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
      +                            }
      +                        }
      +                        $volume = "$source:$target";
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    }
      +                } elseif ($type->value() === 'volume') {
      +                    if ($topLevel->get('volumes')->has($source->value())) {
      +                        $temp = $topLevel->get('volumes')->get($source->value());
      +                        if (data_get($temp, 'driver_opts.type') === 'cifs') {
      +                            continue;
      +                        }
      +                        if (data_get($temp, 'driver_opts.type') === 'nfs') {
      +                            continue;
      +                        }
      +                    }
      +                    $slugWithoutUuid = Str::slug($source, '-');
      +                    $name = "{$uuid}_{$slugWithoutUuid}";
      +
      +                    if ($isPullRequest) {
      +                        $name = addPreviewDeploymentSuffix($name, $pull_request_id);
      +                    }
      +                    if (is_string($volume)) {
      +                        $parsed = parseDockerVolumeString($volume);
      +                        $source = $parsed['source'];
      +                        $target = $parsed['target'];
      +                        $source = $name;
      +                        $volume = "$source:$target";
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } elseif (is_array($volume)) {
      +                        data_set($volume, 'source', $name);
      +                    }
      +                    $topLevel->get('volumes')->put($name, [
      +                        'name' => $name,
      +                    ]);
      +                    LocalPersistentVolume::updateOrCreate(
      +                        [
      +                            'name' => $name,
      +                            'resource_id' => $originalResource->id,
      +                            'resource_type' => get_class($originalResource),
      +                        ],
      +                        [
      +                            'name' => $name,
      +                            'mount_path' => $target,
      +                            'resource_id' => $originalResource->id,
      +                            'resource_type' => get_class($originalResource),
      +                        ]
      +                    );
      +                }
      +                dispatch(new ServerFilesFromServerJob($originalResource));
      +                $volumesParsed->put($index, $volume);
      +            }
      +        }
      +
      +        if ($depends_on?->count() > 0) {
      +            if ($isPullRequest) {
      +                $newDependsOn = collect([]);
      +                $depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) {
      +                    if (is_numeric($condition)) {
      +                        $dependency = addPreviewDeploymentSuffix($dependency, $pullRequestId);
      +
      +                        $newDependsOn->put($condition, $dependency);
      +                    } else {
      +                        $condition = addPreviewDeploymentSuffix($condition, $pullRequestId);
      +                        $newDependsOn->put($condition, $dependency);
      +                    }
      +                });
      +                $depends_on = $newDependsOn;
      +            }
      +        }
      +        if (! $use_network_mode) {
      +            if ($topLevel->get('networks')?->count() > 0) {
      +                foreach ($topLevel->get('networks') as $networkName => $network) {
      +                    if ($networkName === 'default') {
      +                        continue;
      +                    }
      +                    // ignore aliases
      +                    if ($network['aliases'] ?? false) {
      +                        continue;
      +                    }
      +                    $networkExists = $networks->contains(function ($value, $key) use ($networkName) {
      +                        return $value == $networkName || $key == $networkName;
      +                    });
      +                    if (! $networkExists) {
      +                        $networks->put($networkName, null);
      +                    }
      +                }
      +            }
      +            $baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
      +                return $value == $baseNetwork;
      +            });
      +            if (! $baseNetworkExists) {
      +                foreach ($baseNetwork as $network) {
      +                    $topLevel->get('networks')->put($network, [
      +                        'name' => $network,
      +                        'external' => true,
      +                    ]);
      +                }
      +            }
      +        }
      +
      +        // Collect/create/update ports
      +        $collectedPorts = collect([]);
      +        if ($ports->count() > 0) {
      +            foreach ($ports as $sport) {
      +                if (is_string($sport) || is_numeric($sport)) {
      +                    $collectedPorts->push($sport);
      +                }
      +                if (is_array($sport)) {
      +                    $target = data_get($sport, 'target');
      +                    $published = data_get($sport, 'published');
      +                    $protocol = data_get($sport, 'protocol');
      +                    $collectedPorts->push("$target:$published/$protocol");
      +                }
      +            }
      +        }
      +
      +        $networks_temp = collect();
      +
      +        if (! $use_network_mode) {
      +            foreach ($networks as $key => $network) {
      +                if (gettype($network) === 'string') {
      +                    // networks:
      +                    //  - appwrite
      +                    $networks_temp->put($network, null);
      +                } elseif (gettype($network) === 'array') {
      +                    // networks:
      +                    //   default:
      +                    //     ipv4_address: 192.168.203.254
      +                    $networks_temp->put($key, $network);
      +                }
      +            }
      +            foreach ($baseNetwork as $key => $network) {
      +                $networks_temp->put($network, null);
      +            }
      +
      +            if (data_get($resource, 'settings.connect_to_docker_network')) {
      +                $network = $resource->destination->network;
      +                $networks_temp->put($network, null);
      +                $topLevel->get('networks')->put($network, [
      +                    'name' => $network,
      +                    'external' => true,
      +                ]);
      +            }
      +        }
      +
      +        $normalEnvironments = $environment->diffKeys($allMagicEnvironments);
      +        $normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
      +            return ! str($value)->startsWith('SERVICE_');
      +        });
      +        foreach ($normalEnvironments as $key => $value) {
      +            $key = str($key);
      +            $value = str($value);
      +            $originalValue = $value;
      +            $parsedValue = replaceVariables($value);
      +            if ($value->startsWith('$SERVICE_')) {
      +                $resource->environment_variables()->firstOrCreate([
      +                    'key' => $key,
      +                    'resourceable_type' => get_class($resource),
      +                    'resourceable_id' => $resource->id,
      +                ], [
      +                    'value' => $value,
      +                    'is_preview' => false,
      +                ]);
      +
      +                continue;
      +            }
      +            if (! $value->startsWith('$')) {
      +                continue;
      +            }
      +            if ($key->value() === $parsedValue->value()) {
      +                $value = null;
      +                $resource->environment_variables()->firstOrCreate([
      +                    'key' => $key,
      +                    'resourceable_type' => get_class($resource),
      +                    'resourceable_id' => $resource->id,
      +                ], [
      +                    'value' => $value,
      +                    'is_preview' => false,
      +                ]);
      +            } else {
      +                if ($value->startsWith('$')) {
      +                    $isRequired = false;
      +                    if ($value->contains(':-')) {
      +                        $value = replaceVariables($value);
      +                        $key = $value->before(':');
      +                        $value = $value->after(':-');
      +                    } elseif ($value->contains('-')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before('-');
      +                        $value = $value->after('-');
      +                    } elseif ($value->contains(':?')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before(':');
      +                        $value = $value->after(':?');
      +                        $isRequired = true;
      +                    } elseif ($value->contains('?')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before('?');
      +                        $value = $value->after('?');
      +                        $isRequired = true;
      +                    }
      +                    if ($originalValue->value() === $value->value()) {
      +                        // This means the variable does not have a default value, so it needs to be created in Coolify
      +                        $parsedKeyValue = replaceVariables($value);
      +                        $resource->environment_variables()->firstOrCreate([
      +                            'key' => $parsedKeyValue,
      +                            'resourceable_type' => get_class($resource),
      +                            'resourceable_id' => $resource->id,
      +                        ], [
      +                            'is_preview' => false,
      +                            'is_required' => $isRequired,
      +                        ]);
      +                        // Add the variable to the environment so it will be shown in the deployable compose file
      +                        $environment[$parsedKeyValue->value()] = $value;
      +
      +                        continue;
      +                    }
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key,
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $value,
      +                        'is_preview' => false,
      +                        'is_required' => $isRequired,
      +                    ]);
      +                }
      +            }
      +        }
      +        $branch = $originalResource->git_branch;
      +        if ($pullRequestId !== 0) {
      +            $branch = "pull/{$pullRequestId}/head";
      +        }
      +        if ($originalResource->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
      +            $coolifyEnvironments->put('COOLIFY_BRANCH', "\"{$branch}\"");
      +        }
      +
      +        // Add COOLIFY_RESOURCE_UUID to environment
      +        if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
      +            $coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
      +        }
      +
      +        // Add COOLIFY_CONTAINER_NAME to environment
      +        if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
      +            $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
      +        }
      +
      +        if ($isPullRequest) {
      +            $preview = $resource->previews()->find($preview_id);
      +            $domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]);
      +        } else {
      +            $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
      +        }
      +
      +        // Only process domains for dockercompose applications to prevent SERVICE variable recreation
      +        if ($resource->build_pack !== 'dockercompose') {
      +            $domains = collect([]);
      +        }
      +        $changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
      +        $fqdns = data_get($domains, "$changedServiceName.domain");
      +        // Generate SERVICE_FQDN & SERVICE_URL for dockercompose
      +        if ($resource->build_pack === 'dockercompose') {
      +            foreach ($domains as $forServiceName => $domain) {
      +                $parsedDomain = data_get($domain, 'domain');
      +                $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
      +
      +                if (filled($parsedDomain)) {
      +                    $parsedDomain = str($parsedDomain)->explode(',')->first();
      +                    $coolifyUrl = Url::fromString($parsedDomain);
      +                    $coolifyScheme = $coolifyUrl->getScheme();
      +                    $coolifyFqdn = $coolifyUrl->getHost();
      +                    $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
      +                    $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyUrl->__toString());
      +                    $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'), $coolifyFqdn);
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'resourceable_type' => Application::class,
      +                        'resourceable_id' => $resource->id,
      +                        'key' => 'SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
      +                    ], [
      +                        'value' => $coolifyUrl->__toString(),
      +                        'is_preview' => false,
      +                    ]);
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'resourceable_type' => Application::class,
      +                        'resourceable_id' => $resource->id,
      +                        'key' => 'SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_')->replace('.', '_'),
      +                    ], [
      +                        'value' => $coolifyFqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                } else {
      +                    $resource->environment_variables()->where('resourceable_type', Application::class)
      +                        ->where('resourceable_id', $resource->id)
      +                        ->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
      +                        ->update([
      +                            'value' => null,
      +                        ]);
      +                    $resource->environment_variables()->where('resourceable_type', Application::class)
      +                        ->where('resourceable_id', $resource->id)
      +                        ->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
      +                        ->update([
      +                            'value' => null,
      +                        ]);
      +                }
      +            }
      +        }
      +        // If the domain is set, we need to generate the FQDNs for the preview
      +        if (filled($fqdns)) {
      +            $fqdns = str($fqdns)->explode(',');
      +            if ($isPullRequest) {
      +                $preview = $resource->previews()->find($preview_id);
      +                $docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains')));
      +                if ($docker_compose_domains->count() > 0) {
      +                    $found_fqdn = data_get($docker_compose_domains, "$serviceName.domain");
      +                    if ($found_fqdn) {
      +                        $fqdns = collect($found_fqdn);
      +                    } else {
      +                        $fqdns = collect([]);
      +                    }
      +                } else {
      +                    $fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) {
      +                        $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId);
      +                        $url = Url::fromString($fqdn);
      +                        $template = $resource->preview_url_template;
      +                        $host = $url->getHost();
      +                        $schema = $url->getScheme();
      +                        $random = new Cuid2;
      +                        $preview_fqdn = str_replace('{{random}}', $random, $template);
      +                        $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
      +                        $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
      +                        $preview_fqdn = "$schema://$preview_fqdn";
      +                        $preview->fqdn = $preview_fqdn;
      +                        $preview->save();
      +
      +                        return $preview_fqdn;
      +                    });
      +                }
      +            }
      +        }
      +        $defaultLabels = defaultLabels(
      +            id: $resource->id,
      +            name: $containerName,
      +            projectName: $resource->project()->name,
      +            resourceName: $resource->name,
      +            pull_request_id: $pullRequestId,
      +            type: 'application',
      +            environment: $resource->environment->name,
      +        );
      +
      +        $isDatabase = isDatabaseImage($image, $service);
      +        // Add COOLIFY_FQDN & COOLIFY_URL to environment
      +        if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
      +            $fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
      +                return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
      +            });
      +            $coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(','));
      +
      +            $urls = $fqdns->map(function ($fqdn) {
      +                return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
      +            });
      +            $coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(','));
      +        }
      +        add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
      +        if ($environment->count() > 0) {
      +            $environment = $environment->filter(function ($value, $key) {
      +                return ! str($key)->startsWith('SERVICE_FQDN_');
      +            })->map(function ($value, $key) use ($resource) {
      +                // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
      +                if (str($value)->isEmpty()) {
      +                    if ($resource->environment_variables()->where('key', $key)->exists()) {
      +                        $value = $resource->environment_variables()->where('key', $key)->first()->value;
      +                    } else {
      +                        $value = null;
      +                    }
      +                }
      +
      +                return $value;
      +            });
      +        }
      +        $serviceLabels = $labels->merge($defaultLabels);
      +        if ($serviceLabels->count() > 0) {
      +            $isContainerLabelEscapeEnabled = data_get($resource, 'settings.is_container_label_escape_enabled');
      +            if ($isContainerLabelEscapeEnabled) {
      +                $serviceLabels = $serviceLabels->map(function ($value, $key) {
      +                    return escapeDollarSign($value);
      +                });
      +            }
      +        }
      +        if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
      +            $shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
      +            $uuid = $resource->uuid;
      +            $network = data_get($resource, 'destination.network');
      +            if ($isPullRequest) {
      +                $uuid = "{$resource->uuid}-{$pullRequestId}";
      +            }
      +            if ($isPullRequest) {
      +                $network = "{$resource->destination->network}-{$pullRequestId}";
      +            }
      +            if ($shouldGenerateLabelsExactly) {
      +                switch ($server->proxyType()) {
      +                    case ProxyTypes::TRAEFIK->value:
      +                        $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
      +                            uuid: $uuid,
      +                            domains: $fqdns,
      +                            is_force_https_enabled: true,
      +                            serviceLabels: $serviceLabels,
      +                            is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                            is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                            service_name: $serviceName,
      +                            image: $image
      +                        ));
      +                        break;
      +                    case ProxyTypes::CADDY->value:
      +                        $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
      +                            network: $network,
      +                            uuid: $uuid,
      +                            domains: $fqdns,
      +                            is_force_https_enabled: true,
      +                            serviceLabels: $serviceLabels,
      +                            is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                            is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                            service_name: $serviceName,
      +                            image: $image,
      +                            predefinedPort: $predefinedPort
      +                        ));
      +                        break;
      +                }
      +            } else {
      +                $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
      +                    uuid: $uuid,
      +                    domains: $fqdns,
      +                    is_force_https_enabled: true,
      +                    serviceLabels: $serviceLabels,
      +                    is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                    is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                    service_name: $serviceName,
      +                    image: $image
      +                ));
      +                $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
      +                    network: $network,
      +                    uuid: $uuid,
      +                    domains: $fqdns,
      +                    is_force_https_enabled: true,
      +                    serviceLabels: $serviceLabels,
      +                    is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                    is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                    service_name: $serviceName,
      +                    image: $image,
      +                    predefinedPort: $predefinedPort
      +                ));
      +            }
      +        }
      +        data_forget($service, 'volumes.*.content');
      +        data_forget($service, 'volumes.*.isDirectory');
      +        data_forget($service, 'volumes.*.is_directory');
      +        data_forget($service, 'exclude_from_hc');
      +
      +        $volumesParsed = $volumesParsed->map(function ($volume) {
      +            data_forget($volume, 'content');
      +            data_forget($volume, 'is_directory');
      +            data_forget($volume, 'isDirectory');
      +
      +            return $volume;
      +        });
      +
      +        $payload = collect($service)->merge([
      +            'container_name' => $containerName,
      +            'restart' => $restart->value(),
      +            'labels' => $serviceLabels,
      +        ]);
      +        if (! $use_network_mode) {
      +            $payload['networks'] = $networks_temp;
      +        }
      +        if ($ports->count() > 0) {
      +            $payload['ports'] = $ports;
      +        }
      +        if ($volumesParsed->count() > 0) {
      +            $payload['volumes'] = $volumesParsed;
      +        }
      +        if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
      +            $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
      +        }
      +        if ($logging) {
      +            $payload['logging'] = $logging;
      +        }
      +        if ($depends_on->count() > 0) {
      +            $payload['depends_on'] = $depends_on;
      +        }
      +        if ($isPullRequest) {
      +            $serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
      +        }
      +
      +        $parsedServices->put($serviceName, $payload);
      +    }
      +    $topLevel->put('services', $parsedServices);
      +
      +    $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
      +
      +    $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
      +        return array_search($key, $customOrder);
      +    });
      +
      +    $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
      +    data_forget($resource, 'environment_variables');
      +    data_forget($resource, 'environment_variables_preview');
      +    $resource->save();
      +
      +    return $topLevel;
      +}
      +
      +function serviceParser(Service $resource): Collection
      +{
      +    $uuid = data_get($resource, 'uuid');
      +    $compose = data_get($resource, 'docker_compose_raw');
      +    if (! $compose) {
      +        return collect([]);
      +    }
      +
      +    $server = data_get($resource, 'server');
      +    $allServices = get_service_templates();
      +
      +    try {
      +        $yaml = Yaml::parse($compose);
      +    } catch (\Exception) {
      +        return collect([]);
      +    }
      +    $services = data_get($yaml, 'services', collect([]));
      +    $topLevel = collect([
      +        'volumes' => collect(data_get($yaml, 'volumes', [])),
      +        'networks' => collect(data_get($yaml, 'networks', [])),
      +        'configs' => collect(data_get($yaml, 'configs', [])),
      +        'secrets' => collect(data_get($yaml, 'secrets', [])),
      +    ]);
      +    // If there are predefined volumes, make sure they are not null
      +    if ($topLevel->get('volumes')->count() > 0) {
      +        $temp = collect([]);
      +        foreach ($topLevel['volumes'] as $volumeName => $volume) {
      +            if (is_null($volume)) {
      +                continue;
      +            }
      +            $temp->put($volumeName, $volume);
      +        }
      +        $topLevel['volumes'] = $temp;
      +    }
      +    // Get the base docker network
      +    $baseNetwork = collect([$uuid]);
      +
      +    $parsedServices = collect([]);
      +
      +    $allMagicEnvironments = collect([]);
      +    // Presave services
      +    foreach ($services as $serviceName => $service) {
      +        $image = data_get_str($service, 'image');
      +        $isDatabase = isDatabaseImage($image, $service);
      +        if ($isDatabase) {
      +            $applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first();
      +            if ($applicationFound) {
      +                $savedService = $applicationFound;
      +            } else {
      +                $savedService = ServiceDatabase::firstOrCreate([
      +                    'name' => $serviceName,
      +                    'image' => $image,
      +                    'service_id' => $resource->id,
      +                ]);
      +            }
      +        } else {
      +            $savedService = ServiceApplication::firstOrCreate([
      +                'name' => $serviceName,
      +                'image' => $image,
      +                'service_id' => $resource->id,
      +            ]);
      +        }
      +    }
      +    foreach ($services as $serviceName => $service) {
      +        $predefinedPort = null;
      +        $magicEnvironments = collect([]);
      +        $image = data_get_str($service, 'image');
      +        $environment = collect(data_get($service, 'environment', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +        $isDatabase = isDatabaseImage($image, $service);
      +
      +        $containerName = "$serviceName-{$resource->uuid}";
      +
      +        if ($serviceName === 'registry') {
      +            $tempServiceName = 'docker-registry';
      +        } else {
      +            $tempServiceName = $serviceName;
      +        }
      +        if (str(data_get($service, 'image'))->contains('glitchtip')) {
      +            $tempServiceName = 'glitchtip';
      +        }
      +        if ($serviceName === 'supabase-kong') {
      +            $tempServiceName = 'supabase';
      +        }
      +        $serviceDefinition = data_get($allServices, $tempServiceName);
      +        $predefinedPort = data_get($serviceDefinition, 'port');
      +        if ($serviceName === 'plausible') {
      +            $predefinedPort = '8000';
      +        }
      +        if ($isDatabase) {
      +            $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
      +            if ($applicationFound) {
      +                $savedService = $applicationFound;
      +            } else {
      +                $savedService = ServiceDatabase::firstOrCreate([
      +                    'name' => $serviceName,
      +                    'service_id' => $resource->id,
      +                ]);
      +            }
      +        } else {
      +            $savedService = ServiceApplication::firstOrCreate([
      +                'name' => $serviceName,
      +                'service_id' => $resource->id,
      +            ], [
      +                'is_gzip_enabled' => true,
      +            ]);
      +        }
      +        // Check if image changed
      +        if ($savedService->image !== $image) {
      +            $savedService->image = $image;
      +            $savedService->save();
      +        }
      +        // Pocketbase does not need gzip for SSE.
      +        if (str($savedService->image)->contains('pocketbase') && $savedService->is_gzip_enabled) {
      +            $savedService->is_gzip_enabled = false;
      +            $savedService->save();
      +        }
      +
      +        $environment = collect(data_get($service, 'environment', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +
      +        // convert environment variables to one format
      +        $environment = convertToKeyValueCollection($environment);
      +
      +        // Add Coolify defined environments
      +        $allEnvironments = $resource->environment_variables()->get(['key', 'value']);
      +
      +        $allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
      +            return [$item['key'] => $item['value']];
      +        });
      +        // filter and add magic environments
      +        foreach ($environment as $key => $value) {
      +            // Get all SERVICE_ variables from keys and values
      +            $key = str($key);
      +            $value = str($value);
      +            $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
      +            preg_match_all($regex, $value, $valueMatches);
      +            if (count($valueMatches[1]) > 0) {
      +                foreach ($valueMatches[1] as $match) {
      +                    $match = replaceVariables($match);
      +                    if ($match->startsWith('SERVICE_')) {
      +                        if ($magicEnvironments->has($match->value())) {
      +                            continue;
      +                        }
      +                        $magicEnvironments->put($match->value(), '');
      +                    }
      +                }
      +            }
      +            // Get magic environments where we need to preset the FQDN / URL
      +            if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
      +                // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
      +                if (substr_count(str($key)->value(), '_') === 3) {
      +                    if ($key->startsWith('SERVICE_FQDN_')) {
      +                        $urlFor = null;
      +                        $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
      +                    }
      +                    if ($key->startsWith('SERVICE_URL_')) {
      +                        $fqdnFor = null;
      +                        $urlFor = $key->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
      +                    }
      +                    $port = $key->afterLast('_')->value();
      +                } else {
      +                    if ($key->startsWith('SERVICE_FQDN_')) {
      +                        $urlFor = null;
      +                        $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
      +                    }
      +                    if ($key->startsWith('SERVICE_URL_')) {
      +                        $fqdnFor = null;
      +                        $urlFor = $key->after('SERVICE_URL_')->lower()->value();
      +                    }
      +                    $port = null;
      +                }
      +                if (blank($savedService->fqdn)) {
      +                    if ($fqdnFor) {
      +                        $fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
      +                    } else {
      +                        $fqdn = generateFqdn(server: $server, random: "{$savedService->name}-$uuid", parserVersion: $resource->compose_parsing_version);
      +                    }
      +                    if ($urlFor) {
      +                        $url = generateUrl($server, "$urlFor-$uuid");
      +                    } else {
      +                        $url = generateUrl($server, "{$savedService->name}-$uuid");
      +                    }
      +                } else {
      +                    $fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
      +                    $url = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
      +                }
      +
      +                if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
      +                    $path = $value->value();
      +                    if ($path !== '/') {
      +                        $fqdn = "$fqdn$path";
      +                        $url = "$url$path";
      +                    }
      +                }
      +                $fqdnWithPort = $fqdn;
      +                $urlWithPort = $url;
      +                if ($fqdn && $port) {
      +                    $fqdnWithPort = "$fqdn:$port";
      +                }
      +                if ($url && $port) {
      +                    $urlWithPort = "$url:$port";
      +                }
      +                if (is_null($savedService->fqdn)) {
      +                    if ((int) $resource->compose_parsing_version >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
      +                        if ($fqdnFor) {
      +                            $savedService->fqdn = $fqdnWithPort;
      +                        }
      +                        if ($urlFor) {
      +                            $savedService->fqdn = $urlWithPort;
      +                        }
      +                    } else {
      +                        $savedService->fqdn = $fqdnWithPort;
      +                    }
      +                    $savedService->save();
      +                }
      +                if (substr_count(str($key)->value(), '_') === 2) {
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $url,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +                if (substr_count(str($key)->value(), '_') === 3) {
      +                    $newKey = str($key)->beforeLast('_');
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $newKey->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +                    $resource->environment_variables()->updateOrCreate([
      +                        'key' => $newKey->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $url,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +            }
      +        }
      +        $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
      +        if ($magicEnvironments->count() > 0) {
      +            foreach ($magicEnvironments as $key => $value) {
      +                $key = str($key);
      +                $value = replaceVariables($value);
      +                $command = parseCommandFromMagicEnvVariable($key);
      +                if ($command->value() === 'FQDN') {
      +                    $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
      +                    $fqdn = generateFqdn(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version);
      +                    $url = generateUrl(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid");
      +
      +                    $envExists = $resource->environment_variables()->where('key', $key->value())->first();
      +                    $serviceExists = ServiceApplication::where('name', str($fqdnFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
      +                    if (! $envExists && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
      +                        // Save URL otherwise it won't work.
      +                        $serviceExists->fqdn = $url;
      +                        $serviceExists->save();
      +                    }
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $fqdn,
      +                        'is_preview' => false,
      +                    ]);
      +
      +                } elseif ($command->value() === 'URL') {
      +                    $urlFor = $key->after('SERVICE_URL_')->lower()->value();
      +                    $url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
      +
      +                    $envExists = $resource->environment_variables()->where('key', $key->value())->first();
      +                    $serviceExists = ServiceApplication::where('name', str($urlFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
      +                    if (! $envExists && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
      +                        $serviceExists->fqdn = $url;
      +                        $serviceExists->save();
      +                    }
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $url,
      +                        'is_preview' => false,
      +                    ]);
      +
      +                } else {
      +                    $value = generateEnvValue($command, $resource);
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key->value(),
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $value,
      +                        'is_preview' => false,
      +                    ]);
      +                }
      +            }
      +        }
      +    }
      +
      +    $serviceAppsLogDrainEnabledMap = $resource->applications()->get()->keyBy('name')->map(function ($app) {
      +        return $app->isLogDrainEnabled();
      +    });
      +
      +    // Parse the rest of the services
      +    foreach ($services as $serviceName => $service) {
      +        $image = data_get_str($service, 'image');
      +        $restart = data_get_str($service, 'restart', RESTART_MODE);
      +        $logging = data_get($service, 'logging');
      +
      +        if ($server->isLogDrainEnabled()) {
      +            if ($serviceAppsLogDrainEnabledMap->get($serviceName)) {
      +                $logging = generate_fluentd_configuration();
      +            }
      +        }
      +        $volumes = collect(data_get($service, 'volumes', []));
      +        $networks = collect(data_get($service, 'networks', []));
      +        $use_network_mode = data_get($service, 'network_mode') !== null;
      +        $depends_on = collect(data_get($service, 'depends_on', []));
      +        $labels = collect(data_get($service, 'labels', []));
      +        if ($labels->count() > 0) {
      +            if (isAssociativeArray($labels)) {
      +                $newLabels = collect([]);
      +                $labels->each(function ($value, $key) use ($newLabels) {
      +                    $newLabels->push("$key=$value");
      +                });
      +                $labels = $newLabels;
      +            }
      +        }
      +        $environment = collect(data_get($service, 'environment', []));
      +        $ports = collect(data_get($service, 'ports', []));
      +        $buildArgs = collect(data_get($service, 'build.args', []));
      +        $environment = $environment->merge($buildArgs);
      +
      +        $environment = convertToKeyValueCollection($environment);
      +        $coolifyEnvironments = collect([]);
      +
      +        $isDatabase = isDatabaseImage($image, $service);
      +        $volumesParsed = collect([]);
      +
      +        $containerName = "$serviceName-{$resource->uuid}";
      +
      +        if ($serviceName === 'registry') {
      +            $tempServiceName = 'docker-registry';
      +        } else {
      +            $tempServiceName = $serviceName;
      +        }
      +        if (str(data_get($service, 'image'))->contains('glitchtip')) {
      +            $tempServiceName = 'glitchtip';
      +        }
      +        if ($serviceName === 'supabase-kong') {
      +            $tempServiceName = 'supabase';
      +        }
      +        $serviceDefinition = data_get($allServices, $tempServiceName);
      +        $predefinedPort = data_get($serviceDefinition, 'port');
      +        if ($serviceName === 'plausible') {
      +            $predefinedPort = '8000';
      +        }
      +
      +        if ($isDatabase) {
      +            $applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first();
      +            if ($applicationFound) {
      +                $savedService = $applicationFound;
      +            } else {
      +                $savedService = ServiceDatabase::firstOrCreate([
      +                    'name' => $serviceName,
      +                    'image' => $image,
      +                    'service_id' => $resource->id,
      +                ]);
      +            }
      +        } else {
      +            $savedService = ServiceApplication::firstOrCreate([
      +                'name' => $serviceName,
      +                'image' => $image,
      +                'service_id' => $resource->id,
      +            ]);
      +        }
      +        $fileStorages = $savedService->fileStorages();
      +        if ($savedService->image !== $image) {
      +            $savedService->image = $image;
      +            $savedService->save();
      +        }
      +
      +        $originalResource = $savedService;
      +
      +        if ($volumes->count() > 0) {
      +            foreach ($volumes as $index => $volume) {
      +                $type = null;
      +                $source = null;
      +                $target = null;
      +                $content = null;
      +                $isDirectory = false;
      +                if (is_string($volume)) {
      +                    $parsed = parseDockerVolumeString($volume);
      +                    $source = $parsed['source'];
      +                    $target = $parsed['target'];
      +                    // Mode is available in $parsed['mode'] if needed
      +                    $foundConfig = $fileStorages->whereMountPath($target)->first();
      +                    if (sourceIsLocal($source)) {
      +                        $type = str('bind');
      +                        if ($foundConfig) {
      +                            $contentNotNull_temp = data_get($foundConfig, 'content');
      +                            if ($contentNotNull_temp) {
      +                                $content = $contentNotNull_temp;
      +                            }
      +                            $isDirectory = data_get($foundConfig, 'is_directory');
      +                        } else {
      +                            // By default, we cannot determine if the bind is a directory or not, so we set it to directory
      +                            $isDirectory = true;
      +                        }
      +                    } else {
      +                        $type = str('volume');
      +                    }
      +                } elseif (is_array($volume)) {
      +                    $type = data_get_str($volume, 'type');
      +                    $source = data_get_str($volume, 'source');
      +                    $target = data_get_str($volume, 'target');
      +                    $content = data_get($volume, 'content');
      +                    $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
      +
      +                    $foundConfig = $fileStorages->whereMountPath($target)->first();
      +                    if ($foundConfig) {
      +                        $contentNotNull_temp = data_get($foundConfig, 'content');
      +                        if ($contentNotNull_temp) {
      +                            $content = $contentNotNull_temp;
      +                        }
      +                        $isDirectory = data_get($foundConfig, 'is_directory');
      +                    } else {
      +                        // if isDirectory is not set (or false) & content is also not set, we assume it is a directory
      +                        if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
      +                            $isDirectory = true;
      +                        }
      +                    }
      +                }
      +                if ($type->value() === 'bind') {
      +                    if ($source->value() === '/var/run/docker.sock') {
      +                        $volume = $source->value().':'.$target->value();
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
      +                        $volume = $source->value().':'.$target->value();
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } else {
      +                        if ((int) $resource->compose_parsing_version >= 4) {
      +                            $mainDirectory = str(base_configuration_dir().'/services/'.$uuid);
      +                        } else {
      +                            $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
      +                        }
      +                        $source = replaceLocalSource($source, $mainDirectory);
      +                        LocalFileVolume::updateOrCreate(
      +                            [
      +                                'mount_path' => $target,
      +                                'resource_id' => $originalResource->id,
      +                                'resource_type' => get_class($originalResource),
      +                            ],
      +                            [
      +                                'fs_path' => $source,
      +                                'mount_path' => $target,
      +                                'content' => $content,
      +                                'is_directory' => $isDirectory,
      +                                'resource_id' => $originalResource->id,
      +                                'resource_type' => get_class($originalResource),
      +                            ]
      +                        );
      +                        if (isDev()) {
      +                            if ((int) $resource->compose_parsing_version >= 4) {
      +                                $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/services/'.$uuid);
      +                            } else {
      +                                $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
      +                            }
      +                        }
      +                        $volume = "$source:$target";
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    }
      +                } elseif ($type->value() === 'volume') {
      +                    if ($topLevel->get('volumes')->has($source->value())) {
      +                        $temp = $topLevel->get('volumes')->get($source->value());
      +                        if (data_get($temp, 'driver_opts.type') === 'cifs') {
      +                            continue;
      +                        }
      +                        if (data_get($temp, 'driver_opts.type') === 'nfs') {
      +                            continue;
      +                        }
      +                    }
      +                    $slugWithoutUuid = Str::slug($source, '-');
      +                    $name = "{$uuid}_{$slugWithoutUuid}";
      +
      +                    if (is_string($volume)) {
      +                        $parsed = parseDockerVolumeString($volume);
      +                        $source = $parsed['source'];
      +                        $target = $parsed['target'];
      +                        $source = $name;
      +                        $volume = "$source:$target";
      +                        if (isset($parsed['mode']) && $parsed['mode']) {
      +                            $volume .= ':'.$parsed['mode']->value();
      +                        }
      +                    } elseif (is_array($volume)) {
      +                        data_set($volume, 'source', $name);
      +                    }
      +                    $topLevel->get('volumes')->put($name, [
      +                        'name' => $name,
      +                    ]);
      +                    LocalPersistentVolume::updateOrCreate(
      +                        [
      +                            'name' => $name,
      +                            'resource_id' => $originalResource->id,
      +                            'resource_type' => get_class($originalResource),
      +                        ],
      +                        [
      +                            'name' => $name,
      +                            'mount_path' => $target,
      +                            'resource_id' => $originalResource->id,
      +                            'resource_type' => get_class($originalResource),
      +                        ]
      +                    );
      +                }
      +                dispatch(new ServerFilesFromServerJob($originalResource));
      +                $volumesParsed->put($index, $volume);
      +            }
      +        }
      +
      +        if (! $use_network_mode) {
      +            if ($topLevel->get('networks')?->count() > 0) {
      +                foreach ($topLevel->get('networks') as $networkName => $network) {
      +                    if ($networkName === 'default') {
      +                        continue;
      +                    }
      +                    // ignore aliases
      +                    if ($network['aliases'] ?? false) {
      +                        continue;
      +                    }
      +                    $networkExists = $networks->contains(function ($value, $key) use ($networkName) {
      +                        return $value == $networkName || $key == $networkName;
      +                    });
      +                    if (! $networkExists) {
      +                        $networks->put($networkName, null);
      +                    }
      +                }
      +            }
      +            $baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
      +                return $value == $baseNetwork;
      +            });
      +            if (! $baseNetworkExists) {
      +                foreach ($baseNetwork as $network) {
      +                    $topLevel->get('networks')->put($network, [
      +                        'name' => $network,
      +                        'external' => true,
      +                    ]);
      +                }
      +            }
      +        }
      +
      +        // Collect/create/update ports
      +        $collectedPorts = collect([]);
      +        if ($ports->count() > 0) {
      +            foreach ($ports as $sport) {
      +                if (is_string($sport) || is_numeric($sport)) {
      +                    $collectedPorts->push($sport);
      +                }
      +                if (is_array($sport)) {
      +                    $target = data_get($sport, 'target');
      +                    $published = data_get($sport, 'published');
      +                    $protocol = data_get($sport, 'protocol');
      +                    $collectedPorts->push("$target:$published/$protocol");
      +                }
      +            }
      +        }
      +        $originalResource->ports = $collectedPorts->implode(',');
      +        $originalResource->save();
      +
      +        $networks_temp = collect();
      +
      +        if (! $use_network_mode) {
      +            foreach ($networks as $key => $network) {
      +                if (gettype($network) === 'string') {
      +                    // networks:
      +                    //  - appwrite
      +                    $networks_temp->put($network, null);
      +                } elseif (gettype($network) === 'array') {
      +                    // networks:
      +                    //   default:
      +                    //     ipv4_address: 192.168.203.254
      +                    $networks_temp->put($key, $network);
      +                }
      +            }
      +            foreach ($baseNetwork as $key => $network) {
      +                $networks_temp->put($network, null);
      +            }
      +        }
      +
      +        $normalEnvironments = $environment->diffKeys($allMagicEnvironments);
      +        $normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
      +            return ! str($value)->startsWith('SERVICE_');
      +        });
      +        foreach ($normalEnvironments as $key => $value) {
      +            $key = str($key);
      +            $value = str($value);
      +            $originalValue = $value;
      +            $parsedValue = replaceVariables($value);
      +            if ($parsedValue->startsWith('SERVICE_')) {
      +                $resource->environment_variables()->firstOrCreate([
      +                    'key' => $key,
      +                    'resourceable_type' => get_class($resource),
      +                    'resourceable_id' => $resource->id,
      +                ], [
      +                    'value' => $value,
      +                    'is_preview' => false,
      +                ]);
      +
      +                continue;
      +            }
      +            if (! $value->startsWith('$')) {
      +                continue;
      +            }
      +            if ($key->value() === $parsedValue->value()) {
      +                $value = null;
      +                $resource->environment_variables()->firstOrCreate([
      +                    'key' => $key,
      +                    'resourceable_type' => get_class($resource),
      +                    'resourceable_id' => $resource->id,
      +                ], [
      +                    'value' => $value,
      +                    'is_preview' => false,
      +                ]);
      +            } else {
      +                if ($value->startsWith('$')) {
      +                    $isRequired = false;
      +                    if ($value->contains(':-')) {
      +                        $value = replaceVariables($value);
      +                        $key = $value->before(':');
      +                        $value = $value->after(':-');
      +                    } elseif ($value->contains('-')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before('-');
      +                        $value = $value->after('-');
      +                    } elseif ($value->contains(':?')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before(':');
      +                        $value = $value->after(':?');
      +                        $isRequired = true;
      +                    } elseif ($value->contains('?')) {
      +                        $value = replaceVariables($value);
      +
      +                        $key = $value->before('?');
      +                        $value = $value->after('?');
      +                        $isRequired = true;
      +                    }
      +                    if ($originalValue->value() === $value->value()) {
      +                        // This means the variable does not have a default value, so it needs to be created in Coolify
      +                        $parsedKeyValue = replaceVariables($value);
      +                        $resource->environment_variables()->firstOrCreate([
      +                            'key' => $parsedKeyValue,
      +                            'resourceable_type' => get_class($resource),
      +                            'resourceable_id' => $resource->id,
      +                        ], [
      +                            'is_preview' => false,
      +                            'is_required' => $isRequired,
      +                        ]);
      +                        // Add the variable to the environment so it will be shown in the deployable compose file
      +                        $environment[$parsedKeyValue->value()] = $value;
      +
      +                        continue;
      +                    }
      +                    $resource->environment_variables()->firstOrCreate([
      +                        'key' => $key,
      +                        'resourceable_type' => get_class($resource),
      +                        'resourceable_id' => $resource->id,
      +                    ], [
      +                        'value' => $value,
      +                        'is_preview' => false,
      +                        'is_required' => $isRequired,
      +                    ]);
      +                }
      +            }
      +        }
      +
      +        // Add COOLIFY_RESOURCE_UUID to environment
      +        if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
      +            $coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
      +        }
      +
      +        // Add COOLIFY_CONTAINER_NAME to environment
      +        if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
      +            $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
      +        }
      +
      +        if ($savedService->serviceType()) {
      +            $fqdns = generateServiceSpecificFqdns($savedService);
      +        } else {
      +            $fqdns = collect(data_get($savedService, 'fqdns'))->filter();
      +        }
      +
      +        $defaultLabels = defaultLabels(
      +            id: $resource->id,
      +            name: $containerName,
      +            projectName: $resource->project()->name,
      +            resourceName: $resource->name,
      +            type: 'service',
      +            subType: $isDatabase ? 'database' : 'application',
      +            subId: $savedService->id,
      +            subName: $savedService->human_name ?? $savedService->name,
      +            environment: $resource->environment->name,
      +        );
      +
      +        // Add COOLIFY_FQDN & COOLIFY_URL to environment
      +        if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
      +            $fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
      +                return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
      +            });
      +            $coolifyEnvironments->put('COOLIFY_FQDN', $fqdnsWithoutPort->implode(','));
      +            $urls = $fqdns->map(function ($fqdn): Stringable {
      +                return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
      +            });
      +            $coolifyEnvironments->put('COOLIFY_URL', $urls->implode(','));
      +        }
      +        add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
      +        if ($environment->count() > 0) {
      +            $environment = $environment->filter(function ($value, $key) {
      +                return ! str($key)->startsWith('SERVICE_FQDN_');
      +            })->map(function ($value, $key) use ($resource) {
      +                // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
      +                if (str($value)->isEmpty()) {
      +                    if ($resource->environment_variables()->where('key', $key)->exists()) {
      +                        $value = $resource->environment_variables()->where('key', $key)->first()->value;
      +                    } else {
      +                        $value = null;
      +                    }
      +                }
      +
      +                return $value;
      +            });
      +        }
      +        $serviceLabels = $labels->merge($defaultLabels);
      +        if ($serviceLabels->count() > 0) {
      +            $isContainerLabelEscapeEnabled = data_get($resource, 'is_container_label_escape_enabled');
      +            if ($isContainerLabelEscapeEnabled) {
      +                $serviceLabels = $serviceLabels->map(function ($value, $key) {
      +                    return escapeDollarSign($value);
      +                });
      +            }
      +        }
      +        if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
      +            $shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
      +            $uuid = $resource->uuid;
      +            $network = data_get($resource, 'destination.network');
      +            if ($shouldGenerateLabelsExactly) {
      +                switch ($server->proxyType()) {
      +                    case ProxyTypes::TRAEFIK->value:
      +                        $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
      +                            uuid: $uuid,
      +                            domains: $fqdns,
      +                            is_force_https_enabled: true,
      +                            serviceLabels: $serviceLabels,
      +                            is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                            is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                            service_name: $serviceName,
      +                            image: $image
      +                        ));
      +                        break;
      +                    case ProxyTypes::CADDY->value:
      +                        $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
      +                            network: $network,
      +                            uuid: $uuid,
      +                            domains: $fqdns,
      +                            is_force_https_enabled: true,
      +                            serviceLabels: $serviceLabels,
      +                            is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                            is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                            service_name: $serviceName,
      +                            image: $image,
      +                            predefinedPort: $predefinedPort
      +                        ));
      +                        break;
      +                }
      +            } else {
      +                $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
      +                    uuid: $uuid,
      +                    domains: $fqdns,
      +                    is_force_https_enabled: true,
      +                    serviceLabels: $serviceLabels,
      +                    is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                    is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                    service_name: $serviceName,
      +                    image: $image
      +                ));
      +                $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
      +                    network: $network,
      +                    uuid: $uuid,
      +                    domains: $fqdns,
      +                    is_force_https_enabled: true,
      +                    serviceLabels: $serviceLabels,
      +                    is_gzip_enabled: $originalResource->isGzipEnabled(),
      +                    is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
      +                    service_name: $serviceName,
      +                    image: $image,
      +                    predefinedPort: $predefinedPort
      +                ));
      +            }
      +        }
      +        if (data_get($service, 'restart') === 'no' || data_get($service, 'exclude_from_hc')) {
      +            $savedService->update(['exclude_from_status' => true]);
      +        }
      +        data_forget($service, 'volumes.*.content');
      +        data_forget($service, 'volumes.*.isDirectory');
      +        data_forget($service, 'volumes.*.is_directory');
      +        data_forget($service, 'exclude_from_hc');
      +
      +        $volumesParsed = $volumesParsed->map(function ($volume) {
      +            data_forget($volume, 'content');
      +            data_forget($volume, 'is_directory');
      +            data_forget($volume, 'isDirectory');
      +
      +            return $volume;
      +        });
      +
      +        $payload = collect($service)->merge([
      +            'container_name' => $containerName,
      +            'restart' => $restart->value(),
      +            'labels' => $serviceLabels,
      +        ]);
      +        if (! $use_network_mode) {
      +            $payload['networks'] = $networks_temp;
      +        }
      +        if ($ports->count() > 0) {
      +            $payload['ports'] = $ports;
      +        }
      +        if ($volumesParsed->count() > 0) {
      +            $payload['volumes'] = $volumesParsed;
      +        }
      +        if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
      +            $payload['environment'] = $environment->merge($coolifyEnvironments);
      +        }
      +        if ($logging) {
      +            $payload['logging'] = $logging;
      +        }
      +        if ($depends_on->count() > 0) {
      +            $payload['depends_on'] = $depends_on;
      +        }
      +
      +        $parsedServices->put($serviceName, $payload);
      +    }
      +    $topLevel->put('services', $parsedServices);
      +
      +    $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
      +
      +    $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
      +        return array_search($key, $customOrder);
      +    });
      +
      +    $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2);
      +    data_forget($resource, 'environment_variables');
      +    data_forget($resource, 'environment_variables_preview');
      +    $resource->save();
      +
      +    return $topLevel;
      +}
      diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
      index cabdabaa7..5bc1d005e 100644
      --- a/bootstrap/helpers/proxy.php
      +++ b/bootstrap/helpers/proxy.php
      @@ -1,6 +1,6 @@
       map(function ($network) use ($array_of_networks) {
      +    $filtered_networks = collect([]);
      +    $networks->map(function ($network) use ($array_of_networks, $filtered_networks) {
      +        if ($network === 'host') {
      +            return; // network-scoped alias is supported only for containers in user defined networks
      +        }
      +
               $array_of_networks[$network] = [
                   'external' => true,
               ];
      +        $filtered_networks->push($network);
           });
           if ($proxy_type === ProxyTypes::TRAEFIK->value) {
               $labels = [
      @@ -155,7 +161,7 @@ function generate_default_proxy_configuration(Server $server)
                           'extra_hosts' => [
                               'host.docker.internal:host-gateway',
                           ],
      -                    'networks' => $networks->toArray(),
      +                    'networks' => $filtered_networks->toArray(),
                           'ports' => [
                               '80:80',
                               '443:443',
      @@ -237,7 +243,7 @@ function generate_default_proxy_configuration(Server $server)
                               'CADDY_DOCKER_POLLING_INTERVAL=5s',
                               'CADDY_DOCKER_CADDYFILE_PATH=/dynamic/Caddyfile',
                           ],
      -                    'networks' => $networks->toArray(),
      +                    'networks' => $filtered_networks->toArray(),
                           'ports' => [
                               '80:80',
                               '443:443',
      @@ -261,7 +267,7 @@ function generate_default_proxy_configuration(Server $server)
           }
       
           $config = Yaml::dump($config, 12, 2);
      -    SaveConfiguration::run($server, $config);
      +    SaveProxyConfiguration::run($server, $config);
       
           return $config;
       }
      diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php
      index 6c1e2beab..56386a55f 100644
      --- a/bootstrap/helpers/remoteProcess.php
      +++ b/bootstrap/helpers/remoteProcess.php
      @@ -60,15 +60,86 @@ function remote_process(
       
       function instant_scp(string $source, string $dest, Server $server, $throwError = true)
       {
      -    $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
      -    $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
      -    $output = trim($process->output());
      -    $exitCode = $process->exitCode();
      -    if ($exitCode !== 0) {
      -        return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
      -    }
      +    return \App\Helpers\SshRetryHandler::retry(
      +        function () use ($source, $dest, $server) {
      +            $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
      +            $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
       
      -    return $output === 'null' ? null : $output;
      +            $output = trim($process->output());
      +            $exitCode = $process->exitCode();
      +
      +            if ($exitCode !== 0) {
      +                excludeCertainErrors($process->errorOutput(), $exitCode);
      +            }
      +
      +            return $output === 'null' ? null : $output;
      +        },
      +        [
      +            'server' => $server->ip,
      +            'source' => $source,
      +            'dest' => $dest,
      +            'function' => 'instant_scp',
      +        ],
      +        $throwError
      +    );
      +}
      +
      +function transfer_file_to_container(string $content, string $container_path, string $deployment_uuid, Server $server, bool $throwError = true): ?string
      +{
      +    $temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
      +
      +    try {
      +        // Write content to temporary file
      +        file_put_contents($temp_file, $content);
      +
      +        // Generate unique filename for server transfer
      +        $server_temp_file = '/tmp/coolify_env_'.uniqid().'_'.$deployment_uuid;
      +
      +        // Transfer file to server
      +        instant_scp($temp_file, $server_temp_file, $server, $throwError);
      +
      +        // Ensure parent directory exists in container, then copy file
      +        $parent_dir = dirname($container_path);
      +        $commands = [];
      +        if ($parent_dir !== '.' && $parent_dir !== '/') {
      +            $commands[] = executeInDocker($deployment_uuid, "mkdir -p \"$parent_dir\"");
      +        }
      +        $commands[] = "docker cp $server_temp_file $deployment_uuid:$container_path";
      +        $commands[] = "rm -f $server_temp_file";  // Cleanup server temp file
      +
      +        return instant_remote_process_with_timeout($commands, $server, $throwError);
      +
      +    } finally {
      +        // Always cleanup local temp file
      +        if (file_exists($temp_file)) {
      +            unlink($temp_file);
      +        }
      +    }
      +}
      +
      +function transfer_file_to_server(string $content, string $server_path, Server $server, bool $throwError = true): ?string
      +{
      +    $temp_file = tempnam(sys_get_temp_dir(), 'coolify_env_');
      +
      +    try {
      +        // Write content to temporary file
      +        file_put_contents($temp_file, $content);
      +
      +        // Ensure parent directory exists on server
      +        $parent_dir = dirname($server_path);
      +        if ($parent_dir !== '.' && $parent_dir !== '/') {
      +            instant_remote_process_with_timeout(["mkdir -p \"$parent_dir\""], $server, $throwError);
      +        }
      +
      +        // Transfer file directly to server destination
      +        return instant_scp($temp_file, $server_path, $server, $throwError);
      +
      +    } finally {
      +        // Always cleanup local temp file
      +        if (file_exists($temp_file)) {
      +            unlink($temp_file);
      +        }
      +    }
       }
       
       function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
      @@ -79,54 +150,65 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
           }
           $command_string = implode("\n", $command);
       
      -    // $start_time = microtime(true);
      -    $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
      -    $process = Process::timeout(30)->run($sshCommand);
      -    // $end_time = microtime(true);
      +    return \App\Helpers\SshRetryHandler::retry(
      +        function () use ($server, $command_string) {
      +            $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
      +            $process = Process::timeout(30)->run($sshCommand);
       
      -    // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds
      -    // ray('SSH command execution time:', $execution_time.' ms')->orange();
      +            $output = trim($process->output());
      +            $exitCode = $process->exitCode();
       
      -    $output = trim($process->output());
      -    $exitCode = $process->exitCode();
      +            if ($exitCode !== 0) {
      +                excludeCertainErrors($process->errorOutput(), $exitCode);
      +            }
       
      -    if ($exitCode !== 0) {
      -        return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
      -    }
      +            // Sanitize output to ensure valid UTF-8 encoding
      +            $output = $output === 'null' ? null : sanitize_utf8_text($output);
       
      -    // Sanitize output to ensure valid UTF-8 encoding
      -    $output = $output === 'null' ? null : sanitize_utf8_text($output);
      -
      -    return $output;
      +            return $output;
      +        },
      +        [
      +            'server' => $server->ip,
      +            'command_preview' => substr($command_string, 0, 100),
      +            'function' => 'instant_remote_process_with_timeout',
      +        ],
      +        $throwError
      +    );
       }
       
       function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
       {
           $command = $command instanceof Collection ? $command->toArray() : $command;
      +
           if ($server->isNonRoot() && ! $no_sudo) {
               $command = parseCommandsByLineForSudo(collect($command), $server);
           }
           $command_string = implode("\n", $command);
       
      -    // $start_time = microtime(true);
      -    $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
      -    $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand);
      -    // $end_time = microtime(true);
      +    return \App\Helpers\SshRetryHandler::retry(
      +        function () use ($server, $command_string) {
      +            $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
      +            $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand);
       
      -    // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds
      -    // ray('SSH command execution time:', $execution_time.' ms')->orange();
      +            $output = trim($process->output());
      +            $exitCode = $process->exitCode();
       
      -    $output = trim($process->output());
      -    $exitCode = $process->exitCode();
      +            if ($exitCode !== 0) {
      +                excludeCertainErrors($process->errorOutput(), $exitCode);
      +            }
       
      -    if ($exitCode !== 0) {
      -        return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null;
      -    }
      +            // Sanitize output to ensure valid UTF-8 encoding
      +            $output = $output === 'null' ? null : sanitize_utf8_text($output);
       
      -    // Sanitize output to ensure valid UTF-8 encoding
      -    $output = $output === 'null' ? null : sanitize_utf8_text($output);
      -
      -    return $output;
      +            return $output;
      +        },
      +        [
      +            'server' => $server->ip,
      +            'command_preview' => substr($command_string, 0, 100),
      +            'function' => 'instant_remote_process',
      +        ],
      +        $throwError
      +    );
       }
       
       function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
      @@ -136,11 +218,18 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null)
               'Could not resolve hostname',
           ]);
           $ignored = $ignoredErrors->contains(fn ($error) => Str::contains($errorOutput, $error));
      +
      +    // Ensure we always have a meaningful error message
      +    $errorMessage = trim($errorOutput);
      +    if (empty($errorMessage)) {
      +        $errorMessage = "SSH command failed with exit code: $exitCode";
      +    }
      +
           if ($ignored) {
               // TODO: Create new exception and disable in sentry
      -        throw new \RuntimeException($errorOutput, $exitCode);
      +        throw new \RuntimeException($errorMessage, $exitCode);
           }
      -    throw new \RuntimeException($errorOutput, $exitCode);
      +    throw new \RuntimeException($errorMessage, $exitCode);
       }
       
       function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection
      diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php
      index 1e1d2a073..a124272a2 100644
      --- a/bootstrap/helpers/services.php
      +++ b/bootstrap/helpers/services.php
      @@ -1,7 +1,6 @@
       image = $updatedImage;
                   $resource->save();
               }
      +
      +        $serviceName = str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
      +        $resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")->delete();
      +        $resource->service->environment_variables()->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")->delete();
      +
               if ($resource->fqdn) {
                   $resourceFqdns = str($resource->fqdn)->explode(',');
      -            if ($resourceFqdns->count() === 1) {
      -                $resourceFqdns = $resourceFqdns->first();
      -                $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
      -                $fqdn = Url::fromString($resourceFqdns);
      -                $port = $fqdn->getPort();
      -                $path = $fqdn->getPath();
      -                $fqdn = $fqdn->getScheme().'://'.$fqdn->getHost();
      -                $fqdnValue = ($path === '/') ? $fqdn : $fqdn.$path;
      -                EnvironmentVariable::updateOrCreate([
      -                    'resourceable_type' => Service::class,
      -                    'resourceable_id' => $resource->service_id,
      -                    'key' => $variableName,
      -                ], [
      -                    'value' => $fqdnValue,
      -                    'is_build_time' => false,
      -                    'is_preview' => false,
      -                ]);
      -                if ($port) {
      -                    $variableName = $variableName."_$port";
      -                    EnvironmentVariable::updateOrCreate([
      -                        'resourceable_type' => Service::class,
      -                        'resourceable_id' => $resource->service_id,
      -                        'key' => $variableName,
      -                    ], [
      -                        'value' => $fqdnValue,
      -                        'is_build_time' => false,
      -                        'is_preview' => false,
      -                    ]);
      -                }
      -                $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
      -                $url = Url::fromString($fqdn);
      -                $port = $url->getPort();
      -                $path = $url->getPath();
      -                $url = $url->getHost();
      -                $urlValue = str($fqdn)->after('://');
      -                if ($path !== '/') {
      -                    $urlValue = $urlValue.$path;
      -                }
      -                EnvironmentVariable::updateOrCreate([
      +            $resourceFqdns = $resourceFqdns->first();
      +            $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
      +            $url = Url::fromString($resourceFqdns);
      +            $port = $url->getPort();
      +            $path = $url->getPath();
      +            $urlValue = $url->getScheme().'://'.$url->getHost();
      +            $urlValue = ($path === '/') ? $urlValue : $urlValue.$path;
      +            $resource->service->environment_variables()->updateOrCreate([
      +                'resourceable_type' => Service::class,
      +                'resourceable_id' => $resource->service_id,
      +                'key' => $variableName,
      +            ], [
      +                'value' => $urlValue,
      +                'is_preview' => false,
      +            ]);
      +            if ($port) {
      +                $variableName = $variableName."_$port";
      +                $resource->service->environment_variables()->updateOrCreate([
                           'resourceable_type' => Service::class,
                           'resourceable_id' => $resource->service_id,
                           'key' => $variableName,
                       ], [
                           'value' => $urlValue,
      -                    'is_build_time' => false,
                           'is_preview' => false,
                       ]);
      -                if ($port) {
      -                    $variableName = $variableName."_$port";
      -                    EnvironmentVariable::updateOrCreate([
      -                        'resourceable_type' => Service::class,
      -                        'resourceable_id' => $resource->service_id,
      -                        'key' => $variableName,
      -                    ], [
      -                        'value' => $urlValue,
      -                        'is_build_time' => false,
      -                        'is_preview' => false,
      -                    ]);
      -                }
      -            } elseif ($resourceFqdns->count() > 1) {
      -                foreach ($resourceFqdns as $fqdn) {
      -                    $host = Url::fromString($fqdn);
      -                    $port = $host->getPort();
      -                    $url = $host->getHost();
      -                    $path = $host->getPath();
      -                    $host = $host->getScheme().'://'.$host->getHost();
      -                    if ($port) {
      -                        $port_envs = EnvironmentVariable::where('resourceable_type', Service::class)
      -                            ->where('resourceable_id', $resource->service_id)
      -                            ->where('key', 'like', "SERVICE_FQDN_%_$port")
      -                            ->get();
      -                        foreach ($port_envs as $port_env) {
      -                            $service_fqdn = str($port_env->key)->beforeLast('_')->after('SERVICE_FQDN_');
      -                            $env = EnvironmentVariable::where('resourceable_type', Service::class)
      -                                ->where('resourceable_id', $resource->service_id)
      -                                ->where('key', 'SERVICE_FQDN_'.$service_fqdn)
      -                                ->first();
      -                            if ($env) {
      -                                if ($path === '/') {
      -                                    $env->value = $host;
      -                                } else {
      -                                    $env->value = $host.$path;
      -                                }
      -                                $env->save();
      -                            }
      -                            if ($path === '/') {
      -                                $port_env->value = $host;
      -                            } else {
      -                                $port_env->value = $host.$path;
      -                            }
      -                            $port_env->save();
      -                        }
      -                        $port_envs_url = EnvironmentVariable::where('resourceable_type', Service::class)
      -                            ->where('resourceable_id', $resource->service_id)
      -                            ->where('key', 'like', "SERVICE_URL_%_$port")
      -                            ->get();
      -                        foreach ($port_envs_url as $port_env_url) {
      -                            $service_url = str($port_env_url->key)->beforeLast('_')->after('SERVICE_URL_');
      -                            $env = EnvironmentVariable::where('resourceable_type', Service::class)
      -                                ->where('resourceable_id', $resource->service_id)
      -                                ->where('key', 'SERVICE_URL_'.$service_url)
      -                                ->first();
      -                            if ($env) {
      -                                if ($path === '/') {
      -                                    $env->value = $url;
      -                                } else {
      -                                    $env->value = $url.$path;
      -                                }
      -                                $env->save();
      -                            }
      -                            if ($path === '/') {
      -                                $port_env_url->value = $url;
      -                            } else {
      -                                $port_env_url->value = $url.$path;
      -                            }
      -                            $port_env_url->save();
      -                        }
      -                    } else {
      -                        $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
      -                        $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
      -                            ->where('resourceable_id', $resource->service_id)
      -                            ->where('key', $variableName)
      -                            ->first();
      -                        $fqdn = Url::fromString($fqdn);
      -                        $fqdn = $fqdn->getScheme().'://'.$fqdn->getHost().$fqdn->getPath();
      -                        if ($generatedEnv) {
      -                            $generatedEnv->value = $fqdn;
      -                            $generatedEnv->save();
      -                        }
      -                        $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
      -                        $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
      -                            ->where('resourceable_id', $resource->service_id)
      -                            ->where('key', $variableName)
      -                            ->first();
      -                        $url = Url::fromString($fqdn);
      -                        $url = $url->getHost().$url->getPath();
      -                        if ($generatedEnv) {
      -                            $url = str($fqdn)->after('://');
      -                            $generatedEnv->value = $url;
      -                            $generatedEnv->save();
      -                        }
      -                    }
      -                }
                   }
      -        } else {
      -            // If FQDN is removed, delete the corresponding environment variables
      -            $serviceName = str($resource->name)->upper()->replace('-', '_');
      -            EnvironmentVariable::where('resourceable_type', Service::class)
      -                ->where('resourceable_id', $resource->service_id)
      -                ->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")
      -                ->delete();
      -            EnvironmentVariable::where('resourceable_type', Service::class)
      -                ->where('resourceable_id', $resource->service_id)
      -                ->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")
      -                ->delete();
      +            $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_')->replace('.', '_');
      +            $fqdn = Url::fromString($resourceFqdns);
      +            $port = $fqdn->getPort();
      +            $path = $fqdn->getPath();
      +            $fqdn = $fqdn->getHost();
      +            $fqdnValue = str($fqdn)->after('://');
      +            if ($path !== '/') {
      +                $fqdnValue = $fqdnValue.$path;
      +            }
      +            $resource->service->environment_variables()->updateOrCreate([
      +                'resourceable_type' => Service::class,
      +                'resourceable_id' => $resource->service_id,
      +                'key' => $variableName,
      +            ], [
      +                'value' => $fqdnValue,
      +                'is_preview' => false,
      +            ]);
      +            if ($port) {
      +                $variableName = $variableName."_$port";
      +                $resource->service->environment_variables()->updateOrCreate([
      +                    'resourceable_type' => Service::class,
      +                    'resourceable_id' => $resource->service_id,
      +                    'key' => $variableName,
      +                ], [
      +                    'value' => $fqdnValue,
      +                    'is_preview' => false,
      +                ]);
      +            }
               }
           } catch (\Throwable $e) {
               return handleError($e);
      diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
      index 4e77b35c3..656c607bf 100644
      --- a/bootstrap/helpers/shared.php
      +++ b/bootstrap/helpers/shared.php
      @@ -204,7 +204,6 @@ function get_latest_version_of_coolify(): string
       
               return data_get($versions, 'coolify.v4.version');
           } catch (\Throwable $e) {
      -        ray($e->getMessage());
       
               return '0.0.0';
           }
      @@ -402,7 +401,7 @@ function data_get_str($data, $key, $default = null): Stringable
           return str($str);
       }
       
      -function generateFqdn(Server $server, string $random, bool $forceHttps = false): string
      +function generateUrl(Server $server, string $random, bool $forceHttps = false): string
       {
           $wildcard = data_get($server, 'settings.wildcard_domain');
           if (is_null($wildcard) || $wildcard === '') {
      @@ -418,6 +417,27 @@ function generateFqdn(Server $server, string $random, bool $forceHttps = false):
       
           return "$scheme://{$random}.$host$path";
       }
      +function generateFqdn(Server $server, string $random, bool $forceHttps = false, int $parserVersion = 5): string
      +{
      +
      +    $wildcard = data_get($server, 'settings.wildcard_domain');
      +    if (is_null($wildcard) || $wildcard === '') {
      +        $wildcard = sslip($server);
      +    }
      +    $url = Url::fromString($wildcard);
      +    $host = $url->getHost();
      +    $path = $url->getPath() === '/' ? '' : $url->getPath();
      +    $scheme = $url->getScheme();
      +    if ($forceHttps) {
      +        $scheme = 'https';
      +    }
      +
      +    if ($parserVersion >= 5 && version_compare(config('constants.coolify.version'), '4.0.0-beta.420.7', '>=')) {
      +        return "{$random}.$host$path";
      +    }
      +
      +    return "$scheme://{$random}.$host$path";
      +}
       function sslip(Server $server)
       {
           if (isDev() && $server->id === 0) {
      @@ -451,12 +471,12 @@ function get_service_templates(bool $force = false): Collection
       
                   return collect($services);
               } catch (\Throwable) {
      -            $services = File::get(base_path('templates/service-templates.json'));
      +            $services = File::get(base_path('templates/'.config('constants.services.file_name')));
       
                   return collect(json_decode($services))->sortKeys();
               }
           } else {
      -        $services = File::get(base_path('templates/service-templates.json'));
      +        $services = File::get(base_path('templates/'.config('constants.services.file_name')));
       
               return collect(json_decode($services))->sortKeys();
           }
      @@ -945,7 +965,7 @@ function getRealtime()
           }
       }
       
      -function validate_dns_entry(string $fqdn, Server $server)
      +function validateDNSEntry(string $fqdn, Server $server)
       {
           // https://www.cloudflare.com/ips-v4/#
           $cloudflare_ips = collect(['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '172.64.0.0/13', '131.0.72.0/22']);
      @@ -978,7 +998,7 @@ function validate_dns_entry(string $fqdn, Server $server)
                   } else {
                       foreach ($results as $result) {
                           if ($result->getType() == $type) {
      -                        if (ip_match($result->getData(), $cloudflare_ips->toArray(), $match)) {
      +                        if (ipMatch($result->getData(), $cloudflare_ips->toArray(), $match)) {
                                   $found_matching_ip = true;
                                   break;
                               }
      @@ -996,7 +1016,7 @@ function validate_dns_entry(string $fqdn, Server $server)
           return $found_matching_ip;
       }
       
      -function ip_match($ip, $cidrs, &$match = null)
      +function ipMatch($ip, $cidrs, &$match = null)
       {
           foreach ((array) $cidrs as $cidr) {
               [$subnet, $mask] = explode('/', $cidr);
      @@ -1009,223 +1029,63 @@ function ip_match($ip, $cidrs, &$match = null)
       
           return false;
       }
      -function checkIfDomainIsAlreadyUsed(Collection|array $domains, ?string $teamId = null, ?string $uuid = null)
      +
      +function checkIPAgainstAllowlist($ip, $allowlist)
       {
      -    if (is_null($teamId)) {
      -        return response()->json(['error' => 'Team ID is required.'], 400);
      -    }
      -    if (is_array($domains)) {
      -        $domains = collect($domains);
      +    if (empty($allowlist)) {
      +        return false;
           }
       
      -    $domains = $domains->map(function ($domain) {
      -        if (str($domain)->endsWith('/')) {
      -            $domain = str($domain)->beforeLast('/');
      +    foreach ((array) $allowlist as $allowed) {
      +        $allowed = trim($allowed);
      +
      +        if (empty($allowed)) {
      +            continue;
               }
       
      -        return str($domain);
      -    });
      -    $applications = Application::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid']);
      -    $serviceApplications = ServiceApplication::ownedByCurrentTeamAPI($teamId)->get(['fqdn', 'uuid']);
      -    if ($uuid) {
      -        $applications = $applications->filter(fn ($app) => $app->uuid !== $uuid);
      -        $serviceApplications = $serviceApplications->filter(fn ($app) => $app->uuid !== $uuid);
      -    }
      -    $domainFound = false;
      -    foreach ($applications as $app) {
      -        if (is_null($app->fqdn)) {
      -            continue;
      -        }
      -        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      -        foreach ($list_of_domains as $domain) {
      -            if (str($domain)->endsWith('/')) {
      -                $domain = str($domain)->beforeLast('/');
      +        // Check if it's a CIDR notation
      +        if (str_contains($allowed, '/')) {
      +            [$subnet, $mask] = explode('/', $allowed);
      +
      +            // Special case: 0.0.0.0 with any subnet means allow all
      +            if ($subnet === '0.0.0.0') {
      +                return true;
                   }
      -            $naked_domain = str($domain)->value();
      -            if ($domains->contains($naked_domain)) {
      -                $domainFound = true;
      -                break;
      +
      +            $mask = (int) $mask;
      +
      +            // Validate mask
      +            if ($mask < 0 || $mask > 32) {
      +                continue;
                   }
      -        }
      -    }
      -    if ($domainFound) {
      -        return true;
      -    }
      -    foreach ($serviceApplications as $app) {
      -        if (str($app->fqdn)->isEmpty()) {
      -            continue;
      -        }
      -        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      -        foreach ($list_of_domains as $domain) {
      -            if (str($domain)->endsWith('/')) {
      -                $domain = str($domain)->beforeLast('/');
      +
      +            // Calculate network addresses
      +            $ip_long = ip2long($ip);
      +            $subnet_long = ip2long($subnet);
      +
      +            if ($ip_long === false || $subnet_long === false) {
      +                continue;
                   }
      -            $naked_domain = str($domain)->value();
      -            if ($domains->contains($naked_domain)) {
      -                $domainFound = true;
      -                break;
      +
      +            $mask_long = ~((1 << (32 - $mask)) - 1);
      +
      +            if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) {
      +                return true;
                   }
      -        }
      -    }
      -    if ($domainFound) {
      -        return true;
      -    }
      -    $settings = instanceSettings();
      -    if (data_get($settings, 'fqdn')) {
      -        $domain = data_get($settings, 'fqdn');
      -        if (str($domain)->endsWith('/')) {
      -            $domain = str($domain)->beforeLast('/');
      -        }
      -        $naked_domain = str($domain)->value();
      -        if ($domains->contains($naked_domain)) {
      -            return true;
      -        }
      -    }
      -}
      -function check_domain_usage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
      -{
      -    if ($resource) {
      -        if ($resource->getMorphClass() === \App\Models\Application::class && $resource->build_pack === 'dockercompose') {
      -            $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain');
      -            $domains = collect($domains);
               } else {
      -            $domains = collect($resource->fqdns);
      -        }
      -    } elseif ($domain) {
      -        $domains = collect($domain);
      -    } else {
      -        throw new \RuntimeException('No resource or FQDN provided.');
      -    }
      -    $domains = $domains->map(function ($domain) {
      -        if (str($domain)->endsWith('/')) {
      -            $domain = str($domain)->beforeLast('/');
      -        }
      -
      -        return str($domain);
      -    });
      -    $apps = Application::all();
      -    foreach ($apps as $app) {
      -        $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== '');
      -        foreach ($list_of_domains as $domain) {
      -            if (str($domain)->endsWith('/')) {
      -                $domain = str($domain)->beforeLast('/');
      +            // Special case: 0.0.0.0 means allow all
      +            if ($allowed === '0.0.0.0') {
      +                return true;
                   }
      -            $naked_domain = str($domain)->value();
      -            if ($domains->contains($naked_domain)) {
      -                if (data_get($resource, 'uuid')) {
      -                    if ($resource->uuid !== $app->uuid) {
      -                        throw new \RuntimeException("Domain $naked_domain is already in use by another resource: 

      Link: {$app->name}"); - } - } elseif ($domain) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

      Link: {$app->name}"); - } + + // Direct IP comparison + if ($ip === $allowed) { + return true; } } } - $apps = ServiceApplication::all(); - foreach ($apps as $app) { - $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); - foreach ($list_of_domains as $domain) { - if (str($domain)->endsWith('/')) { - $domain = str($domain)->beforeLast('/'); - } - $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { - if (data_get($resource, 'uuid')) { - if ($resource->uuid !== $app->uuid) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

      Link: {$app->service->name}"); - } - } elseif ($domain) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

      Link: {$app->service->name}"); - } - } - } - } - if ($resource) { - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $domain = data_get($settings, 'fqdn'); - if (str($domain)->endsWith('/')) { - $domain = str($domain)->beforeLast('/'); - } - $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { - throw new \RuntimeException("Domain $naked_domain is already in use by this Coolify instance."); - } - } - } -} -function parseCommandsByLineForSudo(Collection $commands, Server $server): array -{ - $commands = $commands->map(function ($line) { - if ( - ! str(trim($line))->startsWith([ - 'cd', - 'command', - 'echo', - 'true', - 'if', - 'fi', - ]) - ) { - return "sudo $line"; - } - - if (str(trim($line))->startsWith('if')) { - return str_replace('if', 'if sudo', $line); - } - - return $line; - }); - - $commands = $commands->map(function ($line) use ($server) { - if (Str::startsWith($line, 'sudo mkdir -p')) { - return "$line && sudo chown -R $server->user:$server->user ".Str::after($line, 'sudo mkdir -p').' && sudo chmod -R o-rwx '.Str::after($line, 'sudo mkdir -p'); - } - - return $line; - }); - - $commands = $commands->map(function ($line) { - $line = str($line); - if (str($line)->contains('$(')) { - $line = $line->replace('$(', '$(sudo '); - } - if (str($line)->contains('||')) { - $line = $line->replace('||', '|| sudo'); - } - if (str($line)->contains('&&')) { - $line = $line->replace('&&', '&& sudo'); - } - if (str($line)->contains(' | ')) { - $line = $line->replace(' | ', ' | sudo '); - } - - return $line->value(); - }); - - return $commands->toArray(); -} -function parseLineForSudo(string $command, Server $server): string -{ - if (! str($command)->startSwith('cd') && ! str($command)->startSwith('command')) { - $command = "sudo $command"; - } - if (Str::startsWith($command, 'sudo mkdir -p')) { - $command = "$command && sudo chown -R $server->user:$server->user ".Str::after($command, 'sudo mkdir -p').' && sudo chmod -R o-rwx '.Str::after($command, 'sudo mkdir -p'); - } - if (str($command)->contains('$(') || str($command)->contains('`')) { - $command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value(); - } - if (str($command)->contains('||')) { - $command = str($command)->replace('||', '|| sudo ')->value(); - } - if (str($command)->contains('&&')) { - $command = str($command)->replace('&&', '&& sudo ')->value(); - } - - return $command; + return false; } function get_public_ips() @@ -1269,30 +1129,77 @@ function get_public_ips() function isAnyDeploymentInprogress() { $runningJobs = ApplicationDeploymentQueue::where('horizon_job_worker', gethostname())->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)->get(); - $basicDetails = $runningJobs->map(function ($job) { - return [ - 'id' => $job->id, - 'created_at' => $job->created_at, - 'application_id' => $job->application_id, - 'server_id' => $job->server_id, - 'horizon_job_id' => $job->horizon_job_id, - 'status' => $job->status, - ]; - }); - echo 'Running jobs: '.json_encode($basicDetails)."\n"; + + if ($runningJobs->isEmpty()) { + echo "No deployments in progress.\n"; + exit(0); + } + $horizonJobIds = []; + $deploymentDetails = []; + foreach ($runningJobs as $runningJob) { $horizonJobStatus = getJobStatus($runningJob->horizon_job_id); if ($horizonJobStatus === 'unknown' || $horizonJobStatus === 'reserved') { $horizonJobIds[] = $runningJob->horizon_job_id; + + // Get application and team information + $application = Application::find($runningJob->application_id); + $teamMembers = []; + $deploymentUrl = ''; + + if ($application) { + // Get team members through the application's project + $team = $application->team(); + if ($team) { + $teamMembers = $team->members()->pluck('email')->toArray(); + } + + // Construct the full deployment URL + if ($runningJob->deployment_url) { + $baseUrl = base_url(); + $deploymentUrl = $baseUrl.$runningJob->deployment_url; + } + } + + $deploymentDetails[] = [ + 'id' => $runningJob->id, + 'application_name' => $runningJob->application_name ?? 'Unknown', + 'server_name' => $runningJob->server_name ?? 'Unknown', + 'deployment_url' => $deploymentUrl, + 'team_members' => $teamMembers, + 'created_at' => $runningJob->created_at->format('Y-m-d H:i:s'), + 'horizon_job_id' => $runningJob->horizon_job_id, + ]; } } + if (count($horizonJobIds) === 0) { - echo "No deployments in progress.\n"; + echo "No active deployments in progress (all jobs completed or failed).\n"; exit(0); } - $horizonJobIds = collect($horizonJobIds)->unique()->toArray(); - echo 'There are '.count($horizonJobIds)." deployments in progress.\n"; + + // Display enhanced deployment information + echo "\n=== Running Deployments ===\n"; + echo 'Total active deployments: '.count($horizonJobIds)."\n\n"; + + foreach ($deploymentDetails as $index => $deployment) { + echo 'Deployment #'.($index + 1).":\n"; + echo ' Application: '.$deployment['application_name']."\n"; + echo ' Server: '.$deployment['server_name']."\n"; + echo ' Started: '.$deployment['created_at']."\n"; + if ($deployment['deployment_url']) { + echo ' URL: '.$deployment['deployment_url']."\n"; + } + if (! empty($deployment['team_members'])) { + echo ' Team members: '.implode(', ', $deployment['team_members'])."\n"; + } else { + echo " Team members: No team members found\n"; + } + echo ' Horizon Job ID: '.$deployment['horizon_job_id']."\n"; + echo "\n"; + } + exit(1); } @@ -1310,143 +1217,6 @@ function customApiValidator(Collection|array $item, array $rules) 'required' => 'This field is required.', ]); } - -function parseServiceVolumes($serviceVolumes, $resource, $topLevelVolumes, $pull_request_id = 0) -{ - $serviceVolumes = $serviceVolumes->map(function ($volume) use ($resource, $topLevelVolumes, $pull_request_id) { - $type = null; - $source = null; - $target = null; - $content = null; - $isDirectory = false; - if (is_string($volume)) { - $source = str($volume)->before(':'); - $target = str($volume)->after(':')->beforeLast(':'); - $foundConfig = $resource->fileStorages()->whereMountPath($target)->first(); - if ($source->startsWith('./') || $source->startsWith('/') || $source->startsWith('~')) { - $type = str('bind'); - if ($foundConfig) { - $contentNotNull = data_get($foundConfig, 'content'); - if ($contentNotNull) { - $content = $contentNotNull; - } - $isDirectory = data_get($foundConfig, 'is_directory'); - } else { - // By default, we cannot determine if the bind is a directory or not, so we set it to directory - $isDirectory = true; - } - } else { - $type = str('volume'); - } - } elseif (is_array($volume)) { - $type = data_get_str($volume, 'type'); - $source = data_get_str($volume, 'source'); - $target = data_get_str($volume, 'target'); - $content = data_get($volume, 'content'); - $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); - $foundConfig = $resource->fileStorages()->whereMountPath($target)->first(); - if ($foundConfig) { - $contentNotNull = data_get($foundConfig, 'content'); - if ($contentNotNull) { - $content = $contentNotNull; - } - $isDirectory = data_get($foundConfig, 'is_directory'); - } else { - $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); - if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) { - // if isDirectory is not set (or false) & content is also not set, we assume it is a directory - $isDirectory = true; - } - } - } - if ($type?->value() === 'bind') { - if ($source->value() === '/var/run/docker.sock') { - return $volume; - } - if ($source->value() === '/tmp' || $source->value() === '/tmp/') { - return $volume; - } - if (get_class($resource) === \App\Models\Application::class) { - $dir = base_configuration_dir().'/applications/'.$resource->uuid; - } else { - $dir = base_configuration_dir().'/services/'.$resource->service->uuid; - } - - if ($source->startsWith('.')) { - $source = $source->replaceFirst('.', $dir); - } - if ($source->startsWith('~')) { - $source = $source->replaceFirst('~', $dir); - } - if ($pull_request_id !== 0) { - $source = $source."-pr-$pull_request_id"; - } - if (! $resource?->settings?->is_preserve_repository_enabled || $foundConfig?->is_based_on_git) { - LocalFileVolume::updateOrCreate( - [ - 'mount_path' => $target, - 'resource_id' => $resource->id, - 'resource_type' => get_class($resource), - ], - [ - 'fs_path' => $source, - 'mount_path' => $target, - 'content' => $content, - 'is_directory' => $isDirectory, - 'resource_id' => $resource->id, - 'resource_type' => get_class($resource), - ] - ); - } - } elseif ($type->value() === 'volume') { - if ($topLevelVolumes->has($source->value())) { - $v = $topLevelVolumes->get($source->value()); - if (data_get($v, 'driver_opts.type') === 'cifs') { - return $volume; - } - } - $slugWithoutUuid = Str::slug($source, '-'); - if (get_class($resource) === \App\Models\Application::class) { - $name = "{$resource->uuid}_{$slugWithoutUuid}"; - } else { - $name = "{$resource->service->uuid}_{$slugWithoutUuid}"; - } - if (is_string($volume)) { - $source = str($volume)->before(':'); - $target = str($volume)->after(':')->beforeLast(':'); - $source = $name; - $volume = "$source:$target"; - } elseif (is_array($volume)) { - data_set($volume, 'source', $name); - } - $topLevelVolumes->put($name, [ - 'name' => $name, - ]); - LocalPersistentVolume::updateOrCreate( - [ - 'mount_path' => $target, - 'resource_id' => $resource->id, - 'resource_type' => get_class($resource), - ], - [ - 'name' => $name, - 'mount_path' => $target, - 'resource_id' => $resource->id, - 'resource_type' => get_class($resource), - ] - ); - } - dispatch(new ServerFilesFromServerJob($resource)); - - return $volume; - }); - - return [ - 'serviceVolumes' => $serviceVolumes, - 'topLevelVolumes' => $topLevelVolumes, - ]; -} - function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null) { if ($resource->getMorphClass() === \App\Models\Service::class) { @@ -1850,7 +1620,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $fqdn, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -1930,7 +1699,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $fqdn, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -1969,7 +1737,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $generatedValue, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2008,7 +1775,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'resourceable_id' => $resource->id, ], [ 'value' => $defaultValue, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2272,12 +2038,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $name->replaceFirst('~', $dir); } if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } else { if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); $volume = str("$name:$mount"); if ($topLevelVolumes->has($name)) { $v = $topLevelVolumes->get($name); @@ -2316,7 +2082,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $volume->before(':'); $mount = $volume->after(':'); if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } @@ -2335,7 +2101,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $source = str($source)->replaceFirst('~', $dir); } if ($pull_request_id !== 0) { - $source = $source."-pr-$pull_request_id"; + $source = addPreviewDeploymentSuffix($source, $pull_request_id); } if ($read_only) { data_set($volume, 'source', $source.':'.$target.':ro'); @@ -2344,7 +2110,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } else { if ($pull_request_id !== 0) { - $source = $source."-pr-$pull_request_id"; + $source = addPreviewDeploymentSuffix($source, $pull_request_id); } if ($read_only) { data_set($volume, 'source', $source.':'.$target.':ro'); @@ -2396,13 +2162,13 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $name->replaceFirst('~', $dir); } if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } else { if ($pull_request_id !== 0) { $uuid = $resource->uuid; - $name = $uuid."-$name-pr-$pull_request_id"; + $name = $uuid.'-'.addPreviewDeploymentSuffix($name, $pull_request_id); $volume = str("$name:$mount"); if ($topLevelVolumes->has($name)) { $v = $topLevelVolumes->get($name); @@ -2444,7 +2210,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $name = $volume->before(':'); $mount = $volume->after(':'); if ($pull_request_id !== 0) { - $name = $name."-pr-$pull_request_id"; + $name = addPreviewDeploymentSuffix($name, $pull_request_id); } $volume = str("$name:$mount"); } @@ -2472,7 +2238,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id === 0) { $source = $uuid."-$source"; } else { - $source = $uuid."-$source-pr-$pull_request_id"; + $source = $uuid.'-'.addPreviewDeploymentSuffix($source, $pull_request_id); } if ($read_only) { data_set($volume, 'source', $source.':'.$target.':ro'); @@ -2512,7 +2278,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($pull_request_id !== 0 && count($serviceDependencies) > 0) { $serviceDependencies = $serviceDependencies->map(function ($dependency) use ($pull_request_id) { - return $dependency."-pr-$pull_request_id"; + return addPreviewDeploymentSuffix($dependency, $pull_request_id); }); data_set($service, 'depends_on', $serviceDependencies->toArray()); } @@ -2699,7 +2465,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $fqdn, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2711,7 +2476,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal EnvironmentVariable::create([ 'key' => $key, 'value' => $generatedValue, - 'is_build_time' => false, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2745,20 +2509,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($foundEnv) { $defaultValue = data_get($foundEnv, 'value'); } - $isBuildTime = data_get($foundEnv, 'is_build_time', false); if ($foundEnv) { $foundEnv->update([ 'key' => $key, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, - 'is_build_time' => $isBuildTime, 'value' => $defaultValue, ]); } else { EnvironmentVariable::create([ 'key' => $key, 'value' => $defaultValue, - 'is_build_time' => $isBuildTime, 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, 'is_preview' => false, @@ -2906,7 +2667,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal }); if ($pull_request_id !== 0) { $services->each(function ($service, $serviceName) use ($pull_request_id, $services) { - $services[$serviceName."-pr-$pull_request_id"] = $service; + $services[addPreviewDeploymentSuffix($serviceName, $pull_request_id)] = $service; data_forget($services, $serviceName); }); } @@ -2927,1008 +2688,6 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } -function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection -{ - $isApplication = $resource instanceof Application; - $isService = $resource instanceof Service; - - $uuid = data_get($resource, 'uuid'); - $compose = data_get($resource, 'docker_compose_raw'); - if (! $compose) { - return collect([]); - } - - if ($isApplication) { - $pullRequestId = $pull_request_id; - $isPullRequest = $pullRequestId == 0 ? false : true; - $server = data_get($resource, 'destination.server'); - $fileStorages = $resource->fileStorages(); - } elseif ($isService) { - $server = data_get($resource, 'server'); - $allServices = get_service_templates(); - } else { - return collect([]); - } - - try { - $yaml = Yaml::parse($compose); - } catch (\Exception) { - return collect([]); - } - $services = data_get($yaml, 'services', collect([])); - $topLevel = collect([ - 'volumes' => collect(data_get($yaml, 'volumes', [])), - 'networks' => collect(data_get($yaml, 'networks', [])), - 'configs' => collect(data_get($yaml, 'configs', [])), - 'secrets' => collect(data_get($yaml, 'secrets', [])), - ]); - // If there are predefined volumes, make sure they are not null - if ($topLevel->get('volumes')->count() > 0) { - $temp = collect([]); - foreach ($topLevel['volumes'] as $volumeName => $volume) { - if (is_null($volume)) { - continue; - } - $temp->put($volumeName, $volume); - } - $topLevel['volumes'] = $temp; - } - // Get the base docker network - $baseNetwork = collect([$uuid]); - if ($isApplication && $isPullRequest) { - $baseNetwork = collect(["{$uuid}-{$pullRequestId}"]); - } - - $parsedServices = collect([]); - - $allMagicEnvironments = collect([]); - foreach ($services as $serviceName => $service) { - $predefinedPort = null; - $magicEnvironments = collect([]); - $image = data_get_str($service, 'image'); - $environment = collect(data_get($service, 'environment', [])); - $buildArgs = collect(data_get($service, 'build.args', [])); - $environment = $environment->merge($buildArgs); - $isDatabase = isDatabaseImage($image, $service); - - if ($isService) { - $containerName = "$serviceName-{$resource->uuid}"; - - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } - if ($isDatabase) { - $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first(); - if ($applicationFound) { - $savedService = $applicationFound; - } else { - $savedService = ServiceDatabase::firstOrCreate([ - 'name' => $serviceName, - 'service_id' => $resource->id, - ]); - } - } else { - $savedService = ServiceApplication::firstOrCreate([ - 'name' => $serviceName, - 'service_id' => $resource->id, - ], [ - 'is_gzip_enabled' => true, - ]); - } - // Check if image changed - if ($savedService->image !== $image) { - $savedService->image = $image; - $savedService->save(); - } - // Pocketbase does not need gzip for SSE. - if (str($savedService->image)->contains('pocketbase') && $savedService->is_gzip_enabled) { - $savedService->is_gzip_enabled = false; - $savedService->save(); - } - } - - $environment = collect(data_get($service, 'environment', [])); - $buildArgs = collect(data_get($service, 'build.args', [])); - $environment = $environment->merge($buildArgs); - - // convert environment variables to one format - $environment = convertToKeyValueCollection($environment); - - // Add Coolify defined environments - $allEnvironments = $resource->environment_variables()->get(['key', 'value']); - - $allEnvironments = $allEnvironments->mapWithKeys(function ($item) { - return [$item['key'] => $item['value']]; - }); - // filter and add magic environments - foreach ($environment as $key => $value) { - // Get all SERVICE_ variables from keys and values - $key = str($key); - $value = str($value); - $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; - preg_match_all($regex, $value, $valueMatches); - if (count($valueMatches[1]) > 0) { - foreach ($valueMatches[1] as $match) { - $match = replaceVariables($match); - if ($match->startsWith('SERVICE_')) { - if ($magicEnvironments->has($match->value())) { - continue; - } - $magicEnvironments->put($match->value(), ''); - } - } - } - // Get magic environments where we need to preset the FQDN - if ($key->startsWith('SERVICE_FQDN_')) { - // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 - if (substr_count(str($key)->value(), '_') === 3) { - $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value(); - $port = $key->afterLast('_')->value(); - } else { - $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); - $port = null; - } - if ($isApplication) { - $fqdn = $resource->fqdn; - if (blank($resource->fqdn)) { - $fqdn = generateFqdn($server, "$uuid"); - } - } elseif ($isService) { - if (blank($savedService->fqdn)) { - if ($fqdnFor) { - $fqdn = generateFqdn($server, "$fqdnFor-$uuid"); - } else { - $fqdn = generateFqdn($server, "{$savedService->name}-$uuid"); - } - } else { - $fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value(); - } - } - - if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) { - $path = $value->value(); - if ($path !== '/') { - $fqdn = "$fqdn$path"; - } - } - $fqdnWithPort = $fqdn; - if ($port) { - $fqdnWithPort = "$fqdn:$port"; - } - if ($isApplication && is_null($resource->fqdn)) { - data_forget($resource, 'environment_variables'); - data_forget($resource, 'environment_variables_preview'); - $resource->fqdn = $fqdnWithPort; - $resource->save(); - } elseif ($isService && is_null($savedService->fqdn)) { - $savedService->fqdn = $fqdnWithPort; - $savedService->save(); - } - - if (substr_count(str($key)->value(), '_') === 2) { - $resource->environment_variables()->updateOrCreate([ - 'key' => $key->value(), - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $fqdn, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } - if (substr_count(str($key)->value(), '_') === 3) { - $newKey = str($key)->beforeLast('_'); - $resource->environment_variables()->updateOrCreate([ - 'key' => $newKey->value(), - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $fqdn, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } - } - } - - $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments); - if ($magicEnvironments->count() > 0) { - foreach ($magicEnvironments as $key => $value) { - $key = str($key); - $value = replaceVariables($value); - $command = parseCommandFromMagicEnvVariable($key); - $found = $resource->environment_variables()->where('key', $key->value())->where('resourceable_type', get_class($resource))->where('resourceable_id', $resource->id)->first(); - if ($found) { - continue; - } - if ($command->value() === 'FQDN') { - if ($isApplication && $resource->build_pack === 'dockercompose') { - continue; - } - $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); - if (str($fqdnFor)->contains('_')) { - $fqdnFor = str($fqdnFor)->before('_'); - } - $fqdn = generateFqdn($server, "$fqdnFor-$uuid"); - $resource->environment_variables()->firstOrCreate([ - 'key' => $key->value(), - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $fqdn, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } elseif ($command->value() === 'URL') { - if ($isApplication && $resource->build_pack === 'dockercompose') { - continue; - } - // For services, only generate URL if explicit FQDN is set - if ($isService && blank($savedService->fqdn)) { - continue; - } - $fqdnFor = $key->after('SERVICE_URL_')->lower()->value(); - if (str($fqdnFor)->contains('_')) { - $fqdnFor = str($fqdnFor)->before('_'); - } - $fqdn = generateFqdn($server, "$fqdnFor-$uuid"); - $fqdn = str($fqdn)->replace('http://', '')->replace('https://', ''); - $resource->environment_variables()->firstOrCreate([ - 'key' => $key->value(), - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $fqdn, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } else { - $value = generateEnvValue($command, $resource); - $resource->environment_variables()->firstOrCreate([ - 'key' => $key->value(), - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } - } - } - } - - $serviceAppsLogDrainEnabledMap = collect([]); - if ($resource instanceof Service) { - $serviceAppsLogDrainEnabledMap = $resource->applications()->get()->keyBy('name')->map(function ($app) { - return $app->isLogDrainEnabled(); - }); - } - - // Parse the rest of the services - foreach ($services as $serviceName => $service) { - $image = data_get_str($service, 'image'); - $restart = data_get_str($service, 'restart', RESTART_MODE); - $logging = data_get($service, 'logging'); - - if ($server->isLogDrainEnabled()) { - if ($resource instanceof Application && $resource->isLogDrainEnabled()) { - $logging = generate_fluentd_configuration(); - } - if ($resource instanceof Service && $serviceAppsLogDrainEnabledMap->get($serviceName)) { - $logging = generate_fluentd_configuration(); - } - } - $volumes = collect(data_get($service, 'volumes', [])); - $networks = collect(data_get($service, 'networks', [])); - $use_network_mode = data_get($service, 'network_mode') !== null; - $depends_on = collect(data_get($service, 'depends_on', [])); - $labels = collect(data_get($service, 'labels', [])); - if ($labels->count() > 0) { - if (isAssociativeArray($labels)) { - $newLabels = collect([]); - $labels->each(function ($value, $key) use ($newLabels) { - $newLabels->push("$key=$value"); - }); - $labels = $newLabels; - } - } - $environment = collect(data_get($service, 'environment', [])); - $ports = collect(data_get($service, 'ports', [])); - $buildArgs = collect(data_get($service, 'build.args', [])); - $environment = $environment->merge($buildArgs); - - $environment = convertToKeyValueCollection($environment); - $coolifyEnvironments = collect([]); - - $isDatabase = isDatabaseImage($image, $service); - $volumesParsed = collect([]); - - if ($isApplication) { - $baseName = generateApplicationContainerName( - application: $resource, - pull_request_id: $pullRequestId - ); - $containerName = "$serviceName-$baseName"; - $predefinedPort = null; - } elseif ($isService) { - $containerName = "$serviceName-{$resource->uuid}"; - - if ($serviceName === 'registry') { - $tempServiceName = 'docker-registry'; - } else { - $tempServiceName = $serviceName; - } - if (str(data_get($service, 'image'))->contains('glitchtip')) { - $tempServiceName = 'glitchtip'; - } - if ($serviceName === 'supabase-kong') { - $tempServiceName = 'supabase'; - } - $serviceDefinition = data_get($allServices, $tempServiceName); - $predefinedPort = data_get($serviceDefinition, 'port'); - if ($serviceName === 'plausible') { - $predefinedPort = '8000'; - } - - if ($isDatabase) { - $applicationFound = ServiceApplication::where('name', $serviceName)->where('image', $image)->where('service_id', $resource->id)->first(); - if ($applicationFound) { - $savedService = $applicationFound; - // $savedService = ServiceDatabase::firstOrCreate([ - // 'name' => $applicationFound->name, - // 'image' => $applicationFound->image, - // 'service_id' => $applicationFound->service_id, - // ]); - // $applicationFound->delete(); - } else { - $savedService = ServiceDatabase::firstOrCreate([ - 'name' => $serviceName, - 'image' => $image, - 'service_id' => $resource->id, - ]); - } - } else { - $savedService = ServiceApplication::firstOrCreate([ - 'name' => $serviceName, - 'image' => $image, - 'service_id' => $resource->id, - ]); - } - $fileStorages = $savedService->fileStorages(); - if ($savedService->image !== $image) { - $savedService->image = $image; - $savedService->save(); - } - } - - $originalResource = $isApplication ? $resource : $savedService; - - if ($volumes->count() > 0) { - foreach ($volumes as $index => $volume) { - $type = null; - $source = null; - $target = null; - $content = null; - $isDirectory = false; - if (is_string($volume)) { - $source = str($volume)->before(':'); - $target = str($volume)->after(':')->beforeLast(':'); - $foundConfig = $fileStorages->whereMountPath($target)->first(); - if (sourceIsLocal($source)) { - $type = str('bind'); - if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } - $isDirectory = data_get($foundConfig, 'is_directory'); - } else { - // By default, we cannot determine if the bind is a directory or not, so we set it to directory - $isDirectory = true; - } - } else { - $type = str('volume'); - } - } elseif (is_array($volume)) { - $type = data_get_str($volume, 'type'); - $source = data_get_str($volume, 'source'); - $target = data_get_str($volume, 'target'); - $content = data_get($volume, 'content'); - $isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null); - - $foundConfig = $fileStorages->whereMountPath($target)->first(); - if ($foundConfig) { - $contentNotNull_temp = data_get($foundConfig, 'content'); - if ($contentNotNull_temp) { - $content = $contentNotNull_temp; - } - $isDirectory = data_get($foundConfig, 'is_directory'); - } else { - // if isDirectory is not set (or false) & content is also not set, we assume it is a directory - if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) { - $isDirectory = true; - } - } - } - if ($type->value() === 'bind') { - if ($source->value() === '/var/run/docker.sock') { - $volume = $source->value().':'.$target->value(); - } elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') { - $volume = $source->value().':'.$target->value(); - } else { - if ((int) $resource->compose_parsing_version >= 4) { - if ($isApplication) { - $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); - } elseif ($isService) { - $mainDirectory = str(base_configuration_dir().'/services/'.$uuid); - } - } else { - $mainDirectory = str(base_configuration_dir().'/applications/'.$uuid); - } - $source = replaceLocalSource($source, $mainDirectory); - if ($isApplication && $isPullRequest) { - $source = $source."-pr-$pullRequestId"; - } - LocalFileVolume::updateOrCreate( - [ - 'mount_path' => $target, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ], - [ - 'fs_path' => $source, - 'mount_path' => $target, - 'content' => $content, - 'is_directory' => $isDirectory, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ] - ); - if (isDev()) { - if ((int) $resource->compose_parsing_version >= 4) { - if ($isApplication) { - $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid); - } elseif ($isService) { - $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/services/'.$uuid); - } - } else { - $source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid); - } - } - $volume = "$source:$target"; - } - } elseif ($type->value() === 'volume') { - if ($topLevel->get('volumes')->has($source->value())) { - $temp = $topLevel->get('volumes')->get($source->value()); - if (data_get($temp, 'driver_opts.type') === 'cifs') { - continue; - } - if (data_get($temp, 'driver_opts.type') === 'nfs') { - continue; - } - } - $slugWithoutUuid = Str::slug($source, '-'); - $name = "{$uuid}_{$slugWithoutUuid}"; - - if ($isApplication && $isPullRequest) { - $name = "{$name}-pr-$pullRequestId"; - } - if (is_string($volume)) { - $source = str($volume)->before(':'); - $target = str($volume)->after(':')->beforeLast(':'); - $source = $name; - $volume = "$source:$target"; - } elseif (is_array($volume)) { - data_set($volume, 'source', $name); - } - $topLevel->get('volumes')->put($name, [ - 'name' => $name, - ]); - LocalPersistentVolume::updateOrCreate( - [ - 'name' => $name, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ], - [ - 'name' => $name, - 'mount_path' => $target, - 'resource_id' => $originalResource->id, - 'resource_type' => get_class($originalResource), - ] - ); - } - dispatch(new ServerFilesFromServerJob($originalResource)); - $volumesParsed->put($index, $volume); - } - } - - if ($depends_on?->count() > 0) { - if ($isApplication && $isPullRequest) { - $newDependsOn = collect([]); - $depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) { - if (is_numeric($condition)) { - $dependency = "$dependency-pr-$pullRequestId"; - - $newDependsOn->put($condition, $dependency); - } else { - $condition = "$condition-pr-$pullRequestId"; - $newDependsOn->put($condition, $dependency); - } - }); - $depends_on = $newDependsOn; - } - } - if (! $use_network_mode) { - if ($topLevel->get('networks')?->count() > 0) { - foreach ($topLevel->get('networks') as $networkName => $network) { - if ($networkName === 'default') { - continue; - } - // ignore aliases - if ($network['aliases'] ?? false) { - continue; - } - $networkExists = $networks->contains(function ($value, $key) use ($networkName) { - return $value == $networkName || $key == $networkName; - }); - if (! $networkExists) { - $networks->put($networkName, null); - } - } - } - $baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) { - return $value == $baseNetwork; - }); - if (! $baseNetworkExists) { - foreach ($baseNetwork as $network) { - $topLevel->get('networks')->put($network, [ - 'name' => $network, - 'external' => true, - ]); - } - } - } - - // Collect/create/update ports - $collectedPorts = collect([]); - if ($ports->count() > 0) { - foreach ($ports as $sport) { - if (is_string($sport) || is_numeric($sport)) { - $collectedPorts->push($sport); - } - if (is_array($sport)) { - $target = data_get($sport, 'target'); - $published = data_get($sport, 'published'); - $protocol = data_get($sport, 'protocol'); - $collectedPorts->push("$target:$published/$protocol"); - } - } - } - if ($isService) { - $originalResource->ports = $collectedPorts->implode(','); - $originalResource->save(); - } - - $networks_temp = collect(); - - if (! $use_network_mode) { - foreach ($networks as $key => $network) { - if (gettype($network) === 'string') { - // networks: - // - appwrite - $networks_temp->put($network, null); - } elseif (gettype($network) === 'array') { - // networks: - // default: - // ipv4_address: 192.168.203.254 - $networks_temp->put($key, $network); - } - } - foreach ($baseNetwork as $key => $network) { - $networks_temp->put($network, null); - } - - if ($isApplication) { - if (data_get($resource, 'settings.connect_to_docker_network')) { - $network = $resource->destination->network; - $networks_temp->put($network, null); - $topLevel->get('networks')->put($network, [ - 'name' => $network, - 'external' => true, - ]); - } - } - } - - $normalEnvironments = $environment->diffKeys($allMagicEnvironments); - $normalEnvironments = $normalEnvironments->filter(function ($value, $key) { - return ! str($value)->startsWith('SERVICE_'); - }); - - foreach ($normalEnvironments as $key => $value) { - $key = str($key); - $value = str($value); - $originalValue = $value; - $parsedValue = replaceVariables($value); - if ($value->startsWith('$SERVICE_')) { - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_build_time' => false, - 'is_preview' => false, - ]); - - continue; - } - if (! $value->startsWith('$')) { - continue; - } - if ($key->value() === $parsedValue->value()) { - $value = null; - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_build_time' => false, - 'is_preview' => false, - ]); - } else { - if ($value->startsWith('$')) { - $isRequired = false; - if ($value->contains(':-')) { - $value = replaceVariables($value); - $key = $value->before(':'); - $value = $value->after(':-'); - } elseif ($value->contains('-')) { - $value = replaceVariables($value); - - $key = $value->before('-'); - $value = $value->after('-'); - } elseif ($value->contains(':?')) { - $value = replaceVariables($value); - - $key = $value->before(':'); - $value = $value->after(':?'); - $isRequired = true; - } elseif ($value->contains('?')) { - $value = replaceVariables($value); - - $key = $value->before('?'); - $value = $value->after('?'); - $isRequired = true; - } - if ($originalValue->value() === $value->value()) { - // This means the variable does not have a default value, so it needs to be created in Coolify - $parsedKeyValue = replaceVariables($value); - $resource->environment_variables()->firstOrCreate([ - 'key' => $parsedKeyValue, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'is_build_time' => false, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); - // Add the variable to the environment so it will be shown in the deployable compose file - // $environment[$parsedKeyValue->value()] = $resource->environment_variables()->where('key', $parsedKeyValue)->where('resourceable_type', get_class($resource))->where('resourceable_id', $resource->id)->first()->real_value; - $environment[$parsedKeyValue->value()] = $value; - - continue; - } - $resource->environment_variables()->firstOrCreate([ - 'key' => $key, - 'resourceable_type' => get_class($resource), - 'resourceable_id' => $resource->id, - ], [ - 'value' => $value, - 'is_build_time' => false, - 'is_preview' => false, - 'is_required' => $isRequired, - ]); - } - } - } - if ($isApplication) { - $branch = $originalResource->git_branch; - if ($pullRequestId !== 0) { - $branch = "pull/{$pullRequestId}/head"; - } - if ($originalResource->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) { - $coolifyEnvironments->put('COOLIFY_BRANCH', "\"{$branch}\""); - } - } - - // Add COOLIFY_RESOURCE_UUID to environment - if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) { - $coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}"); - } - - // Add COOLIFY_CONTAINER_NAME to environment - if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { - $coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}"); - } - - if ($isApplication) { - if ($isPullRequest) { - $preview = $resource->previews()->find($preview_id); - $domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]); - } else { - $domains = collect(json_decode($resource->docker_compose_domains)) ?? collect([]); - } - $fqdns = data_get($domains, "$serviceName.domain"); - // Generate SERVICE_FQDN & SERVICE_URL for dockercompose - if ($resource->build_pack === 'dockercompose') { - foreach ($domains as $forServiceName => $domain) { - $parsedDomain = data_get($domain, 'domain'); - if (filled($parsedDomain)) { - $parsedDomain = str($parsedDomain)->explode(',')->first(); - $coolifyUrl = Url::fromString($parsedDomain); - $coolifyScheme = $coolifyUrl->getScheme(); - $coolifyFqdn = $coolifyUrl->getHost(); - $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); - $coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyUrl->__toString()); - $coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyFqdn); - } - } - } - // If the domain is set, we need to generate the FQDNs for the preview - if (filled($fqdns)) { - $fqdns = str($fqdns)->explode(','); - if ($isPullRequest) { - $preview = $resource->previews()->find($preview_id); - $docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))); - if ($docker_compose_domains->count() > 0) { - $found_fqdn = data_get($docker_compose_domains, "$serviceName.domain"); - if ($found_fqdn) { - $fqdns = collect($found_fqdn); - } else { - $fqdns = collect([]); - } - } else { - $fqdns = $fqdns->map(function ($fqdn) use ($pullRequestId, $resource) { - $preview = ApplicationPreview::findPreviewByApplicationAndPullId($resource->id, $pullRequestId); - $url = Url::fromString($fqdn); - $template = $resource->preview_url_template; - $host = $url->getHost(); - $schema = $url->getScheme(); - $random = new Cuid2; - $preview_fqdn = str_replace('{{random}}', $random, $template); - $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); - $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; - $preview->fqdn = $preview_fqdn; - $preview->save(); - - return $preview_fqdn; - }); - } - } - } - $defaultLabels = defaultLabels( - id: $resource->id, - name: $containerName, - projectName: $resource->project()->name, - resourceName: $resource->name, - pull_request_id: $pullRequestId, - type: 'application', - environment: $resource->environment->name, - ); - - } elseif ($isService) { - if ($savedService->serviceType()) { - $fqdns = generateServiceSpecificFqdns($savedService); - } else { - $fqdns = collect(data_get($savedService, 'fqdns'))->filter(); - } - - $defaultLabels = defaultLabels( - id: $resource->id, - name: $containerName, - projectName: $resource->project()->name, - resourceName: $resource->name, - type: 'service', - subType: $isDatabase ? 'database' : 'application', - subId: $savedService->id, - subName: $savedService->human_name ?? $savedService->name, - environment: $resource->environment->name, - ); - } - // Add COOLIFY_FQDN & COOLIFY_URL to environment - if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { - $fqdnsWithoutPort = $fqdns->map(function ($fqdn) { - return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://')); - }); - $coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(',')); - - $urls = $fqdns->map(function ($fqdn) { - return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':'); - }); - $coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(',')); - } - add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables); - - if ($environment->count() > 0) { - $environment = $environment->filter(function ($value, $key) { - return ! str($key)->startsWith('SERVICE_FQDN_'); - })->map(function ($value, $key) use ($resource) { - // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used - if (str($value)->isEmpty()) { - if ($resource->environment_variables()->where('key', $key)->exists()) { - $value = $resource->environment_variables()->where('key', $key)->first()->value; - } else { - $value = null; - } - } - - return $value; - }); - } - $serviceLabels = $labels->merge($defaultLabels); - if ($serviceLabels->count() > 0) { - if ($isApplication) { - $isContainerLabelEscapeEnabled = data_get($resource, 'settings.is_container_label_escape_enabled'); - } else { - $isContainerLabelEscapeEnabled = data_get($resource, 'is_container_label_escape_enabled'); - } - if ($isContainerLabelEscapeEnabled) { - $serviceLabels = $serviceLabels->map(function ($value, $key) { - return escapeDollarSign($value); - }); - } - } - if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { - if ($isApplication) { - $shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels; - $uuid = $resource->uuid; - $network = data_get($resource, 'destination.network'); - if ($isPullRequest) { - $uuid = "{$resource->uuid}-{$pullRequestId}"; - } - if ($isPullRequest) { - $network = "{$resource->destination->network}-{$pullRequestId}"; - } - } else { - $shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels; - $uuid = $resource->uuid; - $network = data_get($resource, 'destination.network'); - } - if ($shouldGenerateLabelsExactly) { - switch ($server->proxyType()) { - case ProxyTypes::TRAEFIK->value: - $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( - uuid: $uuid, - domains: $fqdns, - is_force_https_enabled: true, - serviceLabels: $serviceLabels, - is_gzip_enabled: $originalResource->isGzipEnabled(), - is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), - service_name: $serviceName, - image: $image - )); - break; - case ProxyTypes::CADDY->value: - $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( - network: $network, - uuid: $uuid, - domains: $fqdns, - is_force_https_enabled: true, - serviceLabels: $serviceLabels, - is_gzip_enabled: $originalResource->isGzipEnabled(), - is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), - service_name: $serviceName, - image: $image, - predefinedPort: $predefinedPort - )); - break; - } - } else { - $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( - uuid: $uuid, - domains: $fqdns, - is_force_https_enabled: true, - serviceLabels: $serviceLabels, - is_gzip_enabled: $originalResource->isGzipEnabled(), - is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), - service_name: $serviceName, - image: $image - )); - $serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy( - network: $network, - uuid: $uuid, - domains: $fqdns, - is_force_https_enabled: true, - serviceLabels: $serviceLabels, - is_gzip_enabled: $originalResource->isGzipEnabled(), - is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), - service_name: $serviceName, - image: $image, - predefinedPort: $predefinedPort - )); - } - } - if ($isService) { - if (data_get($service, 'restart') === 'no' || data_get($service, 'exclude_from_hc')) { - $savedService->update(['exclude_from_status' => true]); - } - } - data_forget($service, 'volumes.*.content'); - data_forget($service, 'volumes.*.isDirectory'); - data_forget($service, 'volumes.*.is_directory'); - data_forget($service, 'exclude_from_hc'); - - $volumesParsed = $volumesParsed->map(function ($volume) { - data_forget($volume, 'content'); - data_forget($volume, 'is_directory'); - data_forget($volume, 'isDirectory'); - - return $volume; - }); - - $payload = collect($service)->merge([ - 'container_name' => $containerName, - 'restart' => $restart->value(), - 'labels' => $serviceLabels, - ]); - if (! $use_network_mode) { - $payload['networks'] = $networks_temp; - } - if ($ports->count() > 0) { - $payload['ports'] = $ports; - } - if ($volumesParsed->count() > 0) { - $payload['volumes'] = $volumesParsed; - } - if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { - $payload['environment'] = $environment->merge($coolifyEnvironments); - } - if ($logging) { - $payload['logging'] = $logging; - } - if ($depends_on->count() > 0) { - $payload['depends_on'] = $depends_on; - } - if ($isApplication && $isPullRequest) { - $serviceName = "{$serviceName}-pr-{$pullRequestId}"; - } - - $parsedServices->put($serviceName, $payload); - } - $topLevel->put('services', $parsedServices); - - $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets']; - - $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) { - return array_search($key, $customOrder); - }); - - $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2); - data_forget($resource, 'environment_variables'); - data_forget($resource, 'environment_variables_preview'); - $resource->save(); - - return $topLevel; -} - function generate_fluentd_configuration(): array { return [ @@ -4288,3 +3047,18 @@ function parseDockerfileInterval(string $something) return $seconds; } + +function addPreviewDeploymentSuffix(string $name, int $pull_request_id = 0): string +{ + return ($pull_request_id === 0) ? $name : $name.'-pr-'.$pull_request_id; +} + +function generateDockerComposeServiceName(mixed $services, int $pullRequestId = 0): Collection +{ + $collection = collect([]); + foreach ($services as $serviceName => $_) { + $collection->put('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper(), addPreviewDeploymentSuffix($serviceName, $pullRequestId)); + } + + return $collection; +} diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 510516a2f..48c3a62c3 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -89,3 +89,22 @@ function allowedPathsForInvalidAccounts() 'livewire/update', ]; } + +function updateStripeCustomerEmail(Team $team, string $newEmail): void +{ + if (! isStripe()) { + return; + } + + $stripe_customer_id = data_get($team, 'subscription.stripe_customer_id'); + if (! $stripe_customer_id) { + return; + } + + Stripe::setApiKey(config('subscription.stripe_api_key')); + + \Stripe\Customer::update( + $stripe_customer_id, + ['email' => $newEmail] + ); +} diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php new file mode 100644 index 000000000..ba252c64f --- /dev/null +++ b/bootstrap/helpers/sudo.php @@ -0,0 +1,101 @@ +map(function ($line) { + if ( + ! str(trim($line))->startsWith([ + 'cd', + 'command', + 'echo', + 'true', + 'if', + 'fi', + ]) + ) { + return "sudo $line"; + } + + if (str(trim($line))->startsWith('if')) { + return str_replace('if', 'if sudo', $line); + } + + return $line; + }); + + $commands = $commands->map(function ($line) use ($server) { + if (Str::startsWith($line, 'sudo mkdir -p')) { + $path = trim(Str::after($line, 'sudo mkdir -p')); + if (shouldChangeOwnership($path)) { + return "$line && sudo chown -R $server->user:$server->user $path && sudo chmod -R o-rwx $path"; + } + + return $line; + } + + return $line; + }); + + $commands = $commands->map(function ($line) { + $line = str($line); + if (str($line)->contains('$(')) { + $line = $line->replace('$(', '$(sudo '); + } + if (str($line)->contains('||')) { + $line = $line->replace('||', '|| sudo'); + } + if (str($line)->contains('&&')) { + $line = $line->replace('&&', '&& sudo'); + } + if (str($line)->contains(' | ')) { + $line = $line->replace(' | ', ' | sudo '); + } + + return $line->value(); + }); + + return $commands->toArray(); +} +function parseLineForSudo(string $command, Server $server): string +{ + if (! str($command)->startSwith('cd') && ! str($command)->startSwith('command')) { + $command = "sudo $command"; + } + if (Str::startsWith($command, 'sudo mkdir -p')) { + $path = trim(Str::after($command, 'sudo mkdir -p')); + if (shouldChangeOwnership($path)) { + $command = "$command && sudo chown -R $server->user:$server->user $path && sudo chmod -R o-rwx $path"; + } + } + if (str($command)->contains('$(') || str($command)->contains('`')) { + $command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value(); + } + if (str($command)->contains('||')) { + $command = str($command)->replace('||', '|| sudo ')->value(); + } + if (str($command)->contains('&&')) { + $command = str($command)->replace('&&', '&& sudo ')->value(); + } + + return $command; +} diff --git a/changelogs/.gitignore b/changelogs/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/changelogs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/composer.json b/composer.json index 68b0fb066..ea466049d 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ "socialiteproviders/zitadel": "^4.2", "spatie/laravel-activitylog": "^4.10.2", "spatie/laravel-data": "^4.17.0", + "spatie/laravel-markdown": "^2.7", "spatie/laravel-ray": "^1.40.2", "spatie/laravel-schemaless-attributes": "^2.5.1", "spatie/url": "^2.4", @@ -61,6 +62,7 @@ "barryvdh/laravel-debugbar": "^3.15.4", "driftingly/rector-laravel": "^2.0.5", "fakerphp/faker": "^1.24.1", + "laravel/boost": "^1.1", "laravel/dusk": "^8.3.3", "laravel/pint": "^1.24", "laravel/telescope": "^5.10", @@ -127,4 +129,4 @@ "@php artisan key:generate --ansi" ] } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 8d170cdc1..6320db071 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "52a680a0eb446dcaa74bc35e158aca57", + "content-hash": "a993799242581bd06b5939005ee458d9", "packages": [ { "name": "amphp/amp", @@ -870,16 +870,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.351.1", + "version": "3.352.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "f3e20c8cdd2cc5827d77a0b3c0872fab89cdf805" + "reference": "7f3ad0da2545b24259273ea7ab892188bae7d91b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/f3e20c8cdd2cc5827d77a0b3c0872fab89cdf805", - "reference": "f3e20c8cdd2cc5827d77a0b3c0872fab89cdf805", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7f3ad0da2545b24259273ea7ab892188bae7d91b", + "reference": "7f3ad0da2545b24259273ea7ab892188bae7d91b", "shasum": "" }, "require": { @@ -961,9 +961,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.351.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.352.0" }, - "time": "2025-07-17T18:07:08+00:00" + "time": "2025-08-01T18:04:23+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1373,16 +1373,16 @@ }, { "name": "doctrine/dbal", - "version": "4.3.0", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "5fe09532be619202d59c70956c6fb20e97933ee3" + "reference": "ac336c95ea9e13433d56ca81c308b39db0e1a2a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/5fe09532be619202d59c70956c6fb20e97933ee3", - "reference": "5fe09532be619202d59c70956c6fb20e97933ee3", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/ac336c95ea9e13433d56ca81c308b39db0e1a2a7", + "reference": "ac336c95ea9e13433d56ca81c308b39db0e1a2a7", "shasum": "" }, "require": { @@ -1459,7 +1459,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.3.0" + "source": "https://github.com/doctrine/dbal/tree/4.3.1" }, "funding": [ { @@ -1475,7 +1475,7 @@ "type": "tidelift" } ], - "time": "2025-06-16T19:31:04+00:00" + "time": "2025-07-22T10:09:51+00:00" }, { "name": "doctrine/deprecations", @@ -2678,16 +2678,16 @@ }, { "name": "laravel/framework", - "version": "v12.20.0", + "version": "v12.21.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "1b9a00f8caf5503c92aa436279172beae1a484ff" + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/1b9a00f8caf5503c92aa436279172beae1a484ff", - "reference": "1b9a00f8caf5503c92aa436279172beae1a484ff", + "url": "https://api.github.com/repos/laravel/framework/zipball/ac8c4e73bf1b5387b709f7736d41427e6af1c93b", + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b", "shasum": "" }, "require": { @@ -2889,7 +2889,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-07-08T15:02:21+00:00" + "time": "2025-07-22T15:41:55+00:00" }, { "name": "laravel/horizon", @@ -3111,16 +3111,16 @@ }, { "name": "laravel/sanctum", - "version": "v4.1.2", + "version": "v4.2.0", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "e4c09e69aecd5a383e0c1b85a6bb501c997d7491" + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/e4c09e69aecd5a383e0c1b85a6bb501c997d7491", - "reference": "e4c09e69aecd5a383e0c1b85a6bb501c997d7491", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", "shasum": "" }, "require": { @@ -3171,7 +3171,7 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2025-07-01T15:49:32+00:00" + "time": "2025-07-09T19:45:24+00:00" }, { "name": "laravel/serializable-closure", @@ -3236,16 +3236,16 @@ }, { "name": "laravel/socialite", - "version": "v5.21.0", + "version": "v5.23.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "d83639499ad14985c9a6a9713b70073300ce998d" + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/d83639499ad14985c9a6a9713b70073300ce998d", - "reference": "d83639499ad14985c9a6a9713b70073300ce998d", + "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", "shasum": "" }, "require": { @@ -3304,7 +3304,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2025-05-19T12:56:37+00:00" + "time": "2025-07-23T14:16:08+00:00" }, { "name": "laravel/tinker", @@ -3510,16 +3510,16 @@ }, { "name": "league/commonmark", - "version": "2.7.0", + "version": "2.7.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", "shasum": "" }, "require": { @@ -3548,7 +3548,7 @@ "symfony/process": "^5.4 | ^6.0 | ^7.0", "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", "unleashedtech/php-coding-standard": "^3.1.1", - "vimeo/psalm": "^4.24.0 || ^5.0.0" + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, "suggest": { "symfony/yaml": "v2.3+ required if using the Front Matter extension" @@ -3613,7 +3613,7 @@ "type": "tidelift" } ], - "time": "2025-05-05T12:20:28+00:00" + "time": "2025-07-20T12:47:49+00:00" }, { "name": "league/config", @@ -4696,16 +4696,16 @@ }, { "name": "nesbot/carbon", - "version": "3.10.1", + "version": "3.10.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" + "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", - "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", + "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", "shasum": "" }, "require": { @@ -4797,7 +4797,7 @@ "type": "tidelift" } ], - "time": "2025-06-21T15:19:35+00:00" + "time": "2025-08-02T09:36:06+00:00" }, { "name": "nette/schema", @@ -4949,16 +4949,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.5.0", + "version": "v5.6.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", "shasum": "" }, "require": { @@ -5001,9 +5001,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" }, - "time": "2025-05-31T08:24:38+00:00" + "time": "2025-07-27T20:03:57+00:00" }, { "name": "nubs/random-name-generator", @@ -6663,16 +6663,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.9", + "version": "v0.12.10", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "1b801844becfe648985372cb4b12ad6840245ace" + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", - "reference": "1b801844becfe648985372cb4b12ad6840245ace", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/6e80abe6f2257121f1eb9a4c55bf29d921025b22", + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22", "shasum": "" }, "require": { @@ -6722,12 +6722,11 @@ "authors": [ { "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" + "email": "justin@justinhileman.info" } ], "description": "An interactive shell for modern PHP.", - "homepage": "http://psysh.org", + "homepage": "https://psysh.org", "keywords": [ "REPL", "console", @@ -6736,9 +6735,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.10" }, - "time": "2025-06-23T02:35:06+00:00" + "time": "2025-08-04T12:39:37+00:00" }, { "name": "purplepixie/phpdns", @@ -7247,16 +7246,16 @@ }, { "name": "sentry/sentry", - "version": "4.14.1", + "version": "4.14.2", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-php.git", - "reference": "a28c4a6f5fda2bf730789a638501d7a737a64eda" + "reference": "bfeec74303d60d3f8bc33701ab3e86f8a8729f17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/a28c4a6f5fda2bf730789a638501d7a737a64eda", - "reference": "a28c4a6f5fda2bf730789a638501d7a737a64eda", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/bfeec74303d60d3f8bc33701ab3e86f8a8729f17", + "reference": "bfeec74303d60d3f8bc33701ab3e86f8a8729f17", "shasum": "" }, "require": { @@ -7320,7 +7319,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-php/issues", - "source": "https://github.com/getsentry/sentry-php/tree/4.14.1" + "source": "https://github.com/getsentry/sentry-php/tree/4.14.2" }, "funding": [ { @@ -7332,7 +7331,7 @@ "type": "custom" } ], - "time": "2025-06-23T15:25:52+00:00" + "time": "2025-07-21T08:28:29+00:00" }, { "name": "sentry/sentry-laravel", @@ -7903,6 +7902,66 @@ ], "time": "2025-05-08T15:41:09+00:00" }, + { + "name": "spatie/commonmark-shiki-highlighter", + "version": "2.5.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/commonmark-shiki-highlighter.git", + "reference": "595c7e0b45d4a63b17dfc1ccbd13532d431ec351" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/commonmark-shiki-highlighter/zipball/595c7e0b45d4a63b17dfc1ccbd13532d431ec351", + "reference": "595c7e0b45d4a63b17dfc1ccbd13532d431ec351", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.4.2", + "php": "^8.0", + "spatie/shiki-php": "^2.2.2", + "symfony/process": "^5.4|^6.4|^7.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.19|^v3.49.0", + "phpunit/phpunit": "^9.5", + "spatie/phpunit-snapshot-assertions": "^4.2.7", + "spatie/ray": "^1.28" + }, + "type": "commonmark-extension", + "autoload": { + "psr-4": { + "Spatie\\CommonMarkShikiHighlighter\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Highlight code blocks with league/commonmark and Shiki", + "homepage": "https://github.com/spatie/commonmark-shiki-highlighter", + "keywords": [ + "commonmark-shiki-highlighter", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/commonmark-shiki-highlighter/tree/2.5.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-01-13T11:25:47+00:00" + }, { "name": "spatie/laravel-activitylog", "version": "4.10.2", @@ -8077,6 +8136,82 @@ ], "time": "2025-06-25T11:36:37+00:00" }, + { + "name": "spatie/laravel-markdown", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-markdown.git", + "reference": "353e7f9fae62826e26cbadef58a12ecf39685280" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-markdown/zipball/353e7f9fae62826e26cbadef58a12ecf39685280", + "reference": "353e7f9fae62826e26cbadef58a12ecf39685280", + "shasum": "" + }, + "require": { + "illuminate/cache": "^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "illuminate/view": "^9.0|^10.0|^11.0|^12.0", + "league/commonmark": "^2.6.0", + "php": "^8.1", + "spatie/commonmark-shiki-highlighter": "^2.5", + "spatie/laravel-package-tools": "^1.4.3" + }, + "require-dev": { + "brianium/paratest": "^6.2|^7.8", + "nunomaduro/collision": "^5.3|^6.0|^7.0|^8.0", + "orchestra/testbench": "^6.15|^7.0|^8.0|^10.0", + "pestphp/pest": "^1.22|^2.0|^3.7", + "phpunit/phpunit": "^9.3|^11.5.3", + "spatie/laravel-ray": "^1.23", + "spatie/pest-plugin-snapshots": "^1.1|^2.2|^3.0", + "vimeo/psalm": "^4.8|^6.7" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\LaravelMarkdown\\MarkdownServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\LaravelMarkdown\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "A highly configurable markdown renderer and Blade component for Laravel", + "homepage": "https://github.com/spatie/laravel-markdown", + "keywords": [ + "Laravel-Markdown", + "laravel", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/laravel-markdown/tree/2.7.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-21T13:43:18+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.92.7", @@ -8516,6 +8651,71 @@ ], "time": "2025-04-18T08:17:40+00:00" }, + { + "name": "spatie/shiki-php", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/shiki-php.git", + "reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/shiki-php/zipball/a2e78a9ff8a1290b25d550be8fbf8285c13175c5", + "reference": "a2e78a9ff8a1290b25d550be8fbf8285c13175c5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.0", + "symfony/process": "^5.4|^6.4|^7.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.0", + "pestphp/pest": "^1.8", + "phpunit/phpunit": "^9.5", + "spatie/pest-plugin-snapshots": "^1.1", + "spatie/ray": "^1.10" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\ShikiPhp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rias Van der Veken", + "email": "rias@spatie.be", + "role": "Developer" + }, + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Highlight code using Shiki in PHP", + "homepage": "https://github.com/spatie/shiki-php", + "keywords": [ + "shiki", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/shiki-php/tree/2.3.2" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-02-21T14:16:57+00:00" + }, { "name": "spatie/url", "version": "2.4.0", @@ -8779,16 +8979,16 @@ }, { "name": "symfony/console", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101" + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101", - "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101", + "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", "shasum": "" }, "require": { @@ -8853,7 +9053,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.1" + "source": "https://github.com/symfony/console/tree/v7.3.2" }, "funding": [ { @@ -8864,12 +9064,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-30T17:13:41+00:00" }, { "name": "symfony/css-selector", @@ -9005,16 +9209,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235" + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235", - "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", "shasum": "" }, "require": { @@ -9062,7 +9266,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.1" + "source": "https://github.com/symfony/error-handler/tree/v7.3.2" }, "funding": [ { @@ -9073,12 +9277,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:48:40+00:00" + "time": "2025-07-07T08:17:57+00:00" }, { "name": "symfony/event-dispatcher", @@ -9238,16 +9446,16 @@ }, { "name": "symfony/finder", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", + "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", "shasum": "" }, "require": { @@ -9282,7 +9490,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.0" + "source": "https://github.com/symfony/finder/tree/v7.3.2" }, "funding": [ { @@ -9293,25 +9501,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-30T19:00:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "23dd60256610c86a3414575b70c596e5deff6ed9" + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9", - "reference": "23dd60256610c86a3414575b70c596e5deff6ed9", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", "shasum": "" }, "require": { @@ -9361,7 +9573,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.1" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" }, "funding": [ { @@ -9372,25 +9584,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-23T15:07:14+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831" + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831", - "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c", "shasum": "" }, "require": { @@ -9475,7 +9691,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.1" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.2" }, "funding": [ { @@ -9486,25 +9702,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-28T08:24:55+00:00" + "time": "2025-07-31T10:45:04+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368" + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368", - "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "url": "https://api.github.com/repos/symfony/mailer/zipball/d43e84d9522345f96ad6283d5dfccc8c1cfc299b", + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b", "shasum": "" }, "require": { @@ -9555,7 +9775,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.1" + "source": "https://github.com/symfony/mailer/tree/v7.3.2" }, "funding": [ { @@ -9566,25 +9786,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/mime", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", "shasum": "" }, "require": { @@ -9639,7 +9863,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.0" + "source": "https://github.com/symfony/mime/tree/v7.3.2" }, "funding": [ { @@ -9650,25 +9874,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-19T08:51:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca" + "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/afb9a8038025e5dbc657378bfab9198d75f10fca", - "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/119bcf13e67dbd188e5dbc74228b1686f66acd37", + "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37", "shasum": "" }, "require": { @@ -9706,7 +9934,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.3.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.3.2" }, "funding": [ { @@ -9717,12 +9945,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-04T13:12:05+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/polyfill-ctype", @@ -10587,16 +10819,16 @@ }, { "name": "symfony/routing", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "8e213820c5fea844ecea29203d2a308019007c15" + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", - "reference": "8e213820c5fea844ecea29203d2a308019007c15", + "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", "shasum": "" }, "require": { @@ -10648,7 +10880,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.0" + "source": "https://github.com/symfony/routing/tree/v7.3.2" }, "funding": [ { @@ -10659,12 +10891,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-24T20:43:28+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/service-contracts", @@ -10813,16 +11049,16 @@ }, { "name": "symfony/string", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", + "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", "shasum": "" }, "require": { @@ -10880,7 +11116,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.0" + "source": "https://github.com/symfony/string/tree/v7.3.2" }, "funding": [ { @@ -10891,25 +11127,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-20T20:19:01+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/translation", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063" + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063", + "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90", "shasum": "" }, "require": { @@ -10976,7 +11216,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.1" + "source": "https://github.com/symfony/translation/tree/v7.3.2" }, "funding": [ { @@ -10987,12 +11227,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-30T17:31:46+00:00" }, { "name": "symfony/translation-contracts", @@ -11148,16 +11392,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42" + "reference": "53205bea27450dc5c65377518b3275e126d45e75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", - "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", + "reference": "53205bea27450dc5c65377518b3275e126d45e75", "shasum": "" }, "require": { @@ -11169,7 +11413,6 @@ "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", "symfony/console": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", @@ -11212,7 +11455,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.1" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" }, "funding": [ { @@ -11223,25 +11466,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-29T20:02:46+00:00" }, { "name": "symfony/yaml", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb" + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb", - "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", "shasum": "" }, "require": { @@ -11284,7 +11531,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.1" + "source": "https://github.com/symfony/yaml/tree/v7.3.2" }, "funding": [ { @@ -11295,12 +11542,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-03T06:57:57+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -12039,16 +12290,16 @@ "packages-dev": [ { "name": "barryvdh/laravel-debugbar", - "version": "v3.15.4", + "version": "v3.16.0", "source": { "type": "git", "url": "https://github.com/barryvdh/laravel-debugbar.git", - "reference": "c0667ea91f7185f1e074402c5788195e96bf8106" + "reference": "f265cf5e38577d42311f1a90d619bcd3740bea23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/c0667ea91f7185f1e074402c5788195e96bf8106", - "reference": "c0667ea91f7185f1e074402c5788195e96bf8106", + "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/f265cf5e38577d42311f1a90d619bcd3740bea23", + "reference": "f265cf5e38577d42311f1a90d619bcd3740bea23", "shasum": "" }, "require": { @@ -12056,7 +12307,7 @@ "illuminate/session": "^9|^10|^11|^12", "illuminate/support": "^9|^10|^11|^12", "php": "^8.1", - "php-debugbar/php-debugbar": "~2.1.1", + "php-debugbar/php-debugbar": "~2.2.0", "symfony/finder": "^6|^7" }, "require-dev": { @@ -12076,7 +12327,7 @@ ] }, "branch-alias": { - "dev-master": "3.15-dev" + "dev-master": "3.16-dev" } }, "autoload": { @@ -12108,7 +12359,7 @@ ], "support": { "issues": "https://github.com/barryvdh/laravel-debugbar/issues", - "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.15.4" + "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.16.0" }, "funding": [ { @@ -12120,7 +12371,7 @@ "type": "github" } ], - "time": "2025-04-16T06:32:06+00:00" + "time": "2025-07-14T11:56:43+00:00" }, { "name": "brianium/paratest", @@ -12496,6 +12747,71 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "laravel/boost", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "70f909465bf73dad7e791fad8b7716b3b2712076" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/70f909465bf73dad7e791fad8b7716b3b2712076", + "reference": "70f909465bf73dad7e791fad8b7716b3b2712076", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "laravel/mcp": "^0.1.1", + "laravel/prompts": "^0.1.9|^0.3", + "laravel/roster": "^0.2.5", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2025-09-04T12:16:09+00:00" + }, { "name": "laravel/dusk", "version": "v8.3.3", @@ -12570,6 +12886,70 @@ }, "time": "2025-06-10T13:59:27+00:00" }, + { + "name": "laravel/mcp", + "version": "v0.1.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", + "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Workbench\\App\\": "workbench/app/", + "Laravel\\Mcp\\Tests\\": "tests/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The easiest way to add MCP servers to your Laravel app.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "dev", + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-08-16T09:50:43+00:00" + }, { "name": "laravel/pint", "version": "v1.24.0", @@ -12640,17 +13020,78 @@ "time": "2025-07-10T18:09:32+00:00" }, { - "name": "laravel/telescope", - "version": "v5.10.0", + "name": "laravel/roster", + "version": "v0.2.6", "source": { "type": "git", - "url": "https://github.com/laravel/telescope.git", - "reference": "fc0a8662682c0375b534033873debb780c003486" + "url": "https://github.com/laravel/roster.git", + "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/telescope/zipball/fc0a8662682c0375b534033873debb780c003486", - "reference": "fc0a8662682c0375b534033873debb780c003486", + "url": "https://api.github.com/repos/laravel/roster/zipball/5615acdf860c5a5c61d04aba44f2d3312550c514", + "reference": "5615acdf860c5a5c61d04aba44f2d3312550c514", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-09-04T07:31:39+00:00" + }, + { + "name": "laravel/telescope", + "version": "v5.10.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/telescope.git", + "reference": "6d249d93ab06dc147ac62ea02b4272c2e7a24b72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/telescope/zipball/6d249d93ab06dc147ac62ea02b4272c2e7a24b72", + "reference": "6d249d93ab06dc147ac62ea02b4272c2e7a24b72", "shasum": "" }, "require": { @@ -12704,9 +13145,9 @@ ], "support": { "issues": "https://github.com/laravel/telescope/issues", - "source": "https://github.com/laravel/telescope/tree/v5.10.0" + "source": "https://github.com/laravel/telescope/tree/v5.10.2" }, - "time": "2025-07-07T14:47:19+00:00" + "time": "2025-07-24T05:26:13+00:00" }, { "name": "mockery/mockery", @@ -12793,16 +13234,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.3", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", - "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -12841,7 +13282,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -12849,7 +13290,7 @@ "type": "tidelift" } ], - "time": "2025-07-05T12:25:42+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nunomaduro/collision", @@ -13394,16 +13835,16 @@ }, { "name": "php-debugbar/php-debugbar", - "version": "v2.1.6", + "version": "v2.2.4", "source": { "type": "git", "url": "https://github.com/php-debugbar/php-debugbar.git", - "reference": "16fa68da5617220594aa5e33fa9de415f94784a0" + "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/16fa68da5617220594aa5e33fa9de415f94784a0", - "reference": "16fa68da5617220594aa5e33fa9de415f94784a0", + "url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/3146d04671f51f69ffec2a4207ac3bdcf13a9f35", + "reference": "3146d04671f51f69ffec2a4207ac3bdcf13a9f35", "shasum": "" }, "require": { @@ -13411,6 +13852,9 @@ "psr/log": "^1|^2|^3", "symfony/var-dumper": "^4|^5|^6|^7" }, + "replace": { + "maximebf/debugbar": "self.version" + }, "require-dev": { "dbrekelmans/bdi": "^1", "phpunit/phpunit": "^8|^9", @@ -13425,7 +13869,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "2.1-dev" } }, "autoload": { @@ -13458,9 +13902,9 @@ ], "support": { "issues": "https://github.com/php-debugbar/php-debugbar/issues", - "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.1.6" + "source": "https://github.com/php-debugbar/php-debugbar/tree/v2.2.4" }, - "time": "2025-02-21T17:47:03+00:00" + "time": "2025-07-22T14:01:30+00:00" }, { "name": "php-webdriver/webdriver", @@ -13530,16 +13974,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.18", + "version": "2.1.21", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "ee1f390b7a70cdf74a2b737e554f68afea885db7" + "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ee1f390b7a70cdf74a2b737e554f68afea885db7", - "reference": "ee1f390b7a70cdf74a2b737e554f68afea885db7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1ccf445757458c06a04eb3f803603cb118fe5fa6", + "reference": "1ccf445757458c06a04eb3f803603cb118fe5fa6", "shasum": "" }, "require": { @@ -13584,7 +14028,7 @@ "type": "github" } ], - "time": "2025-07-17T17:22:31+00:00" + "time": "2025-07-28T19:35:08+00:00" }, { "name": "phpunit/php-code-coverage", @@ -15436,16 +15880,16 @@ }, { "name": "symfony/http-client", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64" + "reference": "1c064a0c67749923483216b081066642751cc2c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/4403d87a2c16f33345dca93407a8714ee8c05a64", - "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64", + "url": "https://api.github.com/repos/symfony/http-client/zipball/1c064a0c67749923483216b081066642751cc2c7", + "reference": "1c064a0c67749923483216b081066642751cc2c7", "shasum": "" }, "require": { @@ -15511,7 +15955,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.1" + "source": "https://github.com/symfony/http-client/tree/v7.3.2" }, "funding": [ { @@ -15522,12 +15966,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-28T07:58:39+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/http-client-contracts", diff --git a/config/constants.php b/config/constants.php index c7a36d311..224f2dfb5 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,8 +2,8 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.420.7', - 'helper_version' => '1.0.9', + 'version' => '4.0.0-beta.429', + 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), @@ -12,6 +12,7 @@ return [ 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), + 'releases_url' => 'https://cdn.coollabs.io/coolify/releases.json', ], 'urls' => [ @@ -22,7 +23,8 @@ return [ 'services' => [ // Temporary disabled until cache is implemented // 'official' => 'https://cdn.coollabs.io/coolify/service-templates.json', - 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/main/templates/service-templates.json', + 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/v4.x/templates/service-templates-latest.json', + 'file_name' => 'service-templates-latest.json', ], 'terminal' => [ @@ -57,9 +59,16 @@ return [ 'ssh' => [ 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)), 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), + 'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true), + 'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5), + 'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes 'connection_timeout' => 10, 'server_interval' => 20, 'command_timeout' => 7200, + 'max_retries' => env('SSH_MAX_RETRIES', 3), + 'retry_base_delay' => env('SSH_RETRY_BASE_DELAY', 2), // seconds + 'retry_max_delay' => env('SSH_RETRY_MAX_DELAY', 30), // seconds + 'retry_multiplier' => env('SSH_RETRY_MULTIPLIER', 2), ], 'invitation' => [ @@ -69,6 +78,10 @@ return [ ], ], + 'email_change' => [ + 'verification_code_expiry_minutes' => 10, + ], + 'sentry' => [ 'sentry_dsn' => env('SENTRY_DSN'), ], diff --git a/config/services.php b/config/services.php index 7add50a5c..6a21cda18 100644 --- a/config/services.php +++ b/config/services.php @@ -65,6 +65,6 @@ return [ 'client_secret' => env('ZITADEL_CLIENT_SECRET'), 'redirect' => env('ZITADEL_REDIRECT_URI'), 'base_url' => env('ZITADEL_BASE_URL'), - ] + ], ]; diff --git a/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php b/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php new file mode 100644 index 000000000..db8a42fb7 --- /dev/null +++ b/database/migrations/2025_08_07_142403_create_user_changelog_reads_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('release_tag'); // GitHub tag_name (e.g., "v4.0.0-beta.420.6") + $table->timestamp('read_at'); + $table->timestamps(); + + $table->unique(['user_id', 'release_tag']); + $table->index('user_id'); + $table->index('release_tag'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_changelog_reads'); + } +}; diff --git a/database/migrations/2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table.php b/database/migrations/2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table.php new file mode 100644 index 000000000..e414472df --- /dev/null +++ b/database/migrations/2025_08_17_102422_add_disable_local_backup_to_scheduled_database_backups_table.php @@ -0,0 +1,28 @@ +boolean('disable_local_backup')->default(false)->after('save_s3'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('scheduled_database_backups', function (Blueprint $table) { + $table->dropColumn('disable_local_backup'); + }); + } +}; diff --git a/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php b/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php new file mode 100644 index 000000000..9cefe2c09 --- /dev/null +++ b/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php @@ -0,0 +1,30 @@ +string('pending_email')->nullable()->after('email'); + $table->string('email_change_code', 6)->nullable()->after('pending_email'); + $table->timestamp('email_change_code_expires_at')->nullable()->after('email_change_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['pending_email', 'email_change_code', 'email_change_code_expires_at']); + }); + } +}; diff --git a/database/migrations/2025_08_18_154244_change_env_sorting_default_to_false.php b/database/migrations/2025_08_18_154244_change_env_sorting_default_to_false.php new file mode 100644 index 000000000..32ed075ba --- /dev/null +++ b/database/migrations/2025_08_18_154244_change_env_sorting_default_to_false.php @@ -0,0 +1,18 @@ +boolean('is_env_sorting_enabled')->default(false)->change(); + }); + } +}; diff --git a/database/migrations/2025_08_21_080234_add_git_shallow_clone_to_application_settings_table.php b/database/migrations/2025_08_21_080234_add_git_shallow_clone_to_application_settings_table.php new file mode 100644 index 000000000..399c49c7f --- /dev/null +++ b/database/migrations/2025_08_21_080234_add_git_shallow_clone_to_application_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_git_shallow_clone_enabled')->default(true)->after('is_git_lfs_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('is_git_shallow_clone_enabled'); + }); + } +}; diff --git a/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php b/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php new file mode 100644 index 000000000..5d84ce42d --- /dev/null +++ b/database/migrations/2025_09_05_142446_add_pr_deployments_public_enabled_to_application_settings.php @@ -0,0 +1,28 @@ +boolean('is_pr_deployments_public_enabled')->default(false)->after('is_preview_deployments_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('is_pr_deployments_public_enabled'); + }); + } +}; diff --git a/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php b/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php new file mode 100644 index 000000000..31398bd35 --- /dev/null +++ b/database/migrations/2025_09_10_172952_remove_is_readonly_from_local_persistent_volumes_table.php @@ -0,0 +1,28 @@ +dropColumn('is_readonly'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('local_persistent_volumes', function (Blueprint $table) { + $table->boolean('is_readonly')->default(false); + }); + } +}; diff --git a/database/migrations/2025_09_10_173300_drop_webhooks_table.php b/database/migrations/2025_09_10_173300_drop_webhooks_table.php new file mode 100644 index 000000000..4cb1b4e70 --- /dev/null +++ b/database/migrations/2025_09_10_173300_drop_webhooks_table.php @@ -0,0 +1,31 @@ +id(); + $table->enum('status', ['pending', 'success', 'failed'])->default('pending'); + $table->string('type'); + $table->longText('payload'); + $table->longText('failure_reason')->nullable(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2025_09_10_173402_drop_kubernetes_table.php b/database/migrations/2025_09_10_173402_drop_kubernetes_table.php new file mode 100644 index 000000000..329ed0e7e --- /dev/null +++ b/database/migrations/2025_09_10_173402_drop_kubernetes_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('uuid')->unique(); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php b/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php new file mode 100644 index 000000000..076ee8e09 --- /dev/null +++ b/database/migrations/2025_09_11_143432_remove_is_build_time_from_environment_variables_table.php @@ -0,0 +1,38 @@ +dropColumn('is_build_time'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + // Re-add the is_build_time column + if (! Schema::hasColumn('environment_variables', 'is_build_time')) { + $table->boolean('is_build_time')->default(false)->after('value'); + } + }); + } +}; diff --git a/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php b/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php new file mode 100644 index 000000000..d95f351d5 --- /dev/null +++ b/database/migrations/2025_09_11_150344_add_is_buildtime_only_to_environment_variables_table.php @@ -0,0 +1,28 @@ +boolean('is_buildtime_only')->default(false)->after('is_preview'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('is_buildtime_only'); + }); + } +}; diff --git a/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php new file mode 100644 index 000000000..b78f391fc --- /dev/null +++ b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php @@ -0,0 +1,28 @@ +boolean('use_build_secrets')->default(false)->after('is_build_server_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('use_build_secrets'); + }); + } +}; diff --git a/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php new file mode 100644 index 000000000..6fd4bfed6 --- /dev/null +++ b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php @@ -0,0 +1,67 @@ +boolean('is_runtime')->default(true)->after('is_buildtime_only'); + $table->boolean('is_buildtime')->default(true)->after('is_runtime'); + }); + + // Migrate existing data from is_buildtime_only to new fields + DB::table('environment_variables') + ->where('is_buildtime_only', true) + ->update([ + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + + DB::table('environment_variables') + ->where('is_buildtime_only', false) + ->update([ + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + // Remove the old is_buildtime_only column + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('is_buildtime_only'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + // Re-add the is_buildtime_only column + $table->boolean('is_buildtime_only')->default(false)->after('is_preview'); + }); + + // Restore data to is_buildtime_only based on new fields + DB::table('environment_variables') + ->where('is_runtime', false) + ->where('is_buildtime', true) + ->update(['is_buildtime_only' => true]); + + DB::table('environment_variables') + ->where('is_runtime', true) + ->update(['is_buildtime_only' => false]); + + // Remove new columns + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn(['is_runtime', 'is_buildtime']); + }); + } +}; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3fadd914c..e8402b7af 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -59,7 +59,7 @@ services: SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"] vite: - image: node:20-alpine + image: node:24-alpine pull_policy: always working_dir: /var/www/html environment: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 57f062202..b90f126a2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -61,7 +61,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.9' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index 519309e39..cd4a307aa 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -103,7 +103,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.6' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.10' pull_policy: always container_name: coolify-realtime restart: always diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 8c7073519..212703798 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -10,9 +10,10 @@ ARG DOCKER_BUILDX_VERSION=0.25.0 # https://github.com/buildpacks/pack/releases ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases -ARG NIXPACKS_VERSION=1.39.0 +ARG NIXPACKS_VERSION=1.40.0 # https://github.com/minio/mc/releases -ARG MINIO_VERSION=RELEASE.2025-05-21T01-59-54Z +ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z + FROM minio/mc:${MINIO_VERSION} AS minio-client diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index 49907cbd4..c445c972c 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -7,7 +7,7 @@ "dependencies": { "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", - "axios": "1.8.4", + "axios": "1.12.0", "cookie": "1.0.2", "dotenv": "16.5.0", "node-pty": "1.0.0", @@ -36,13 +36,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index 7851d7f4d..aec3dbe3d 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -5,7 +5,7 @@ "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", "cookie": "1.0.2", - "axios": "1.8.4", + "axios": "1.12.0", "dotenv": "16.5.0", "node-pty": "1.0.0", "ws": "8.18.1" diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index a2a4b5fa3..6c9628a81 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -120,6 +120,7 @@ COPY --chown=www-data:www-data templates ./templates COPY --chown=www-data:www-data resources/views ./resources/views COPY --chown=www-data:www-data artisan artisan COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml +COPY --chown=www-data:www-data changelogs/ ./changelogs/ RUN composer dump-autoload diff --git a/hooks/pre-commit b/hooks/pre-commit index 029f67917..fc96e9766 100644 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -4,6 +4,19 @@ if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then exec غير مستحسن، لأن خوادم Let's Encrypt مع هذا النطاق العام محدودة المعدل (ستفشل عملية التحقق من شهادة SSL).

      استخدم نطاقك الخاص بدلاً من ذلك." -} +} \ No newline at end of file diff --git a/lang/az.json b/lang/az.json index 92f56ddbc..85cee7589 100644 --- a/lang/az.json +++ b/lang/az.json @@ -11,7 +11,8 @@ "auth.login.infomaniak": "Infomaniak ilə daxil ol", "auth.already_registered": "Qeytiyatınız var?", "auth.confirm_password": "Şifrəni təsdiqləyin", - "auth.forgot_password": "Şifrəmi unutdum", + "auth.forgot_password_link": "Şifrəmi unutdum?", + "auth.forgot_password_heading": "Şifrəni bərpa et", "auth.forgot_password_send_email": "Şifrəni sıfırlamaq üçün e-poçt göndər", "auth.register_now": "Qeydiyyat", "auth.logout": "Çıxış", @@ -39,4 +40,4 @@ "resource.delete_configurations": "Serverdən bütün konfiqurasiya faylları tamamilə silinəcək.", "database.delete_backups_locally": "Bütün ehtiyat nüsxələr lokal yaddaşdan tamamilə silinəcək.", "warning.sslipdomain": "Konfiqurasiya yadda saxlanıldı, lakin sslip domeni ilə https TÖVSİYƏ EDİLMİR, çünki Let's Encrypt serverləri bu ümumi domenlə məhdudlaşdırılır (SSL sertifikatının təsdiqlənməsi uğursuz olacaq).

      Əvəzində öz domeninizdən istifadə edin." -} +} \ No newline at end of file diff --git a/lang/cs.json b/lang/cs.json index 00455aa81..9e5d2c44e 100644 --- a/lang/cs.json +++ b/lang/cs.json @@ -10,7 +10,8 @@ "auth.login.infomaniak": "Přihlásit se pomocí Infomaniak", "auth.already_registered": "Již jste registrováni?", "auth.confirm_password": "Potvrďte heslo", - "auth.forgot_password": "Zapomněli jste heslo", + "auth.forgot_password_link": "Zapomněli jste heslo?", + "auth.forgot_password_heading": "Obnovení hesla", "auth.forgot_password_send_email": "Poslat e-mail pro resetování hesla", "auth.register_now": "Registrovat se", "auth.logout": "Odhlásit se", @@ -30,4 +31,4 @@ "input.recovery_code": "Obnovovací kód", "button.save": "Uložit", "repository.url": "Příklady
      Pro veřejné repozitáře, použijte https://....
      Pro soukromé repozitáře, použijte git@....

      https://github.com/coollabsio/coolify-examples main branch bude zvolena
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch bude vybrána.
      https://gitea.com/sedlav/expressjs.git main branch vybrána.
      https://gitlab.com/andrasbacsai/nodejs-example.git main branch bude vybrána." -} +} \ No newline at end of file diff --git a/lang/de.json b/lang/de.json index f56b21710..fd587de22 100644 --- a/lang/de.json +++ b/lang/de.json @@ -11,7 +11,8 @@ "auth.login.zitadel": "Mit Zitadel anmelden", "auth.already_registered": "Bereits registriert?", "auth.confirm_password": "Passwort bestätigen", - "auth.forgot_password": "Passwort vergessen", + "auth.forgot_password_link": "Passwort vergessen?", + "auth.forgot_password_heading": "Passwort-Wiederherstellung", "auth.forgot_password_send_email": "Passwort zurücksetzen E-Mail senden", "auth.register_now": "Registrieren", "auth.logout": "Abmelden", @@ -31,4 +32,4 @@ "input.recovery_code": "Wiederherstellungscode", "button.save": "Speichern", "repository.url": "Beispiele
      Für öffentliche Repositories benutze https://....
      Für private Repositories benutze git@....

      https://github.com/coollabsio/coolify-examples main Branch wird ausgewählt
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify Branch wird ausgewählt.
      https://gitea.com/sedlav/expressjs.git main Branch wird ausgewählt.
      https://gitlab.com/andrasbacsai/nodejs-example.git main Branch wird ausgewählt." -} +} \ No newline at end of file diff --git a/lang/en.json b/lang/en.json index 4a398a9f9..af7f2145d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -12,7 +12,8 @@ "auth.login.zitadel": "Login with Zitadel", "auth.already_registered": "Already registered?", "auth.confirm_password": "Confirm password", - "auth.forgot_password": "Forgot password", + "auth.forgot_password_link": "Forgot password?", + "auth.forgot_password_heading": "Password recovery", "auth.forgot_password_send_email": "Send password reset email", "auth.register_now": "Register", "auth.logout": "Logout", @@ -40,4 +41,4 @@ "resource.delete_configurations": "Permanently delete all configuration files from the server.", "database.delete_backups_locally": "All backups will be permanently deleted from local storage.", "warning.sslipdomain": "Your configuration is saved, but sslip domain with https is NOT recommended, because Let's Encrypt servers with this public domain are rate limited (SSL certificate validation will fail).

      Use your own domain instead." -} +} \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 73363a9bf..f56387f05 100644 --- a/lang/es.json +++ b/lang/es.json @@ -10,7 +10,8 @@ "auth.login.infomaniak": "Acceder con Infomaniak", "auth.already_registered": "¿Ya estás registrado?", "auth.confirm_password": "Confirmar contraseña", - "auth.forgot_password": "¿Olvidaste tu contraseña?", + "auth.forgot_password_link": "¿Olvidaste tu contraseña?", + "auth.forgot_password_heading": "Recuperación de contraseña", "auth.forgot_password_send_email": "Enviar correo de recuperación de contraseña", "auth.register_now": "Registrar", "auth.logout": "Cerrar sesión", @@ -30,4 +31,4 @@ "input.recovery_code": "Código de recuperación", "button.save": "Guardar", "repository.url": "Examples
      Para repositorios públicos, usar https://....
      Para repositorios privados, usar git@....

      https://github.com/coollabsio/coolify-examples main la rama 'main' será seleccionada.
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify la rama 'nodejs-fastify' será seleccionada.
      https://gitea.com/sedlav/expressjs.git main la rama 'main' será seleccionada.
      https://gitlab.com/andrasbacsai/nodejs-example.git main la rama 'main' será seleccionada." -} +} \ No newline at end of file diff --git a/lang/fa.json b/lang/fa.json index d68049e77..ae22ee946 100644 --- a/lang/fa.json +++ b/lang/fa.json @@ -10,7 +10,8 @@ "auth.login.infomaniak": "ورود با Infomaniak", "auth.already_registered": "قبلاً ثبت نام کرده‌اید؟", "auth.confirm_password": "تایید رمز عبور", - "auth.forgot_password": "فراموشی رمز عبور", + "auth.forgot_password_link": "رمز عبور را فراموش کرده‌اید؟", + "auth.forgot_password_heading": "بازیابی رمز عبور", "auth.forgot_password_send_email": "ارسال ایمیل بازیابی رمز عبور", "auth.register_now": "ثبت نام", "auth.logout": "خروج", @@ -30,4 +31,4 @@ "input.recovery_code": "کد بازیابی", "button.save": "ذخیره", "repository.url": "مثال‌ها
      برای مخازن عمومی، از https://... استفاده کنید.
      برای مخازن خصوصی، از git@... استفاده کنید.

      شاخه main انتخاب خواهد شد.
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify شاخه nodejs-fastify انتخاب خواهد شد.
      https://gitea.com/sedlav/expressjs.git شاخه main انتخاب خواهد شد.
      https://gitlab.com/andrasbacsai/nodejs-example.git شاخه main انتخاب خواهد شد." -} +} \ No newline at end of file diff --git a/lang/fr.json b/lang/fr.json index 2516d0f69..d98a1ebc8 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -11,7 +11,8 @@ "auth.login.infomaniak": "Connexion avec Infomaniak", "auth.already_registered": "Déjà enregistré ?", "auth.confirm_password": "Confirmer le mot de passe", - "auth.forgot_password": "Mot de passe oublié", + "auth.forgot_password_link": "Mot de passe oublié ?", + "auth.forgot_password_heading": "Récupération du mot de passe", "auth.forgot_password_send_email": "Envoyer l'email de réinitialisation de mot de passe", "auth.register_now": "S'enregistrer", "auth.logout": "Déconnexion", @@ -39,4 +40,4 @@ "resource.delete_configurations": "Supprimer définitivement tous les fichiers de configuration du serveur.", "database.delete_backups_locally": "Toutes les sauvegardes seront définitivement supprimées du stockage local.", "warning.sslipdomain": "Votre configuration est enregistrée, mais l'utilisation du domaine sslip avec https N'EST PAS recommandée, car les serveurs Let's Encrypt avec ce domaine public sont limités en taux (la validation du certificat SSL échouera).

      Utilisez plutôt votre propre domaine." -} +} \ No newline at end of file diff --git a/lang/id.json b/lang/id.json index b0e38197a..d85176cda 100644 --- a/lang/id.json +++ b/lang/id.json @@ -11,7 +11,8 @@ "auth.login.infomaniak": "Masuk dengan Infomaniak", "auth.already_registered": "Sudah terdaftar?", "auth.confirm_password": "Konfirmasi kata sandi", - "auth.forgot_password": "Lupa kata sandi", + "auth.forgot_password_link": "Lupa kata sandi?", + "auth.forgot_password_heading": "Pemulihan Kata Sandi", "auth.forgot_password_send_email": "Kirim email reset kata sandi", "auth.register_now": "Daftar", "auth.logout": "Keluar", @@ -39,4 +40,4 @@ "resource.delete_configurations": "Hapus permanen semua file konfigurasi dari server.", "database.delete_backups_locally": "Semua backup akan dihapus permanen dari penyimpanan lokal.", "warning.sslipdomain": "Konfigurasi Anda disimpan, tetapi domain sslip dengan https TIDAK direkomendasikan, karena server Let's Encrypt dengan domain publik ini dibatasi (validasi sertifikat SSL akan gagal).

      Gunakan domain Anda sendiri sebagai gantinya." -} +} \ No newline at end of file diff --git a/lang/it.json b/lang/it.json index c0edc314b..e4c1a9c05 100644 --- a/lang/it.json +++ b/lang/it.json @@ -11,7 +11,8 @@ "auth.login.infomaniak": "Accedi con Infomaniak", "auth.already_registered": "Già registrato?", "auth.confirm_password": "Conferma password", - "auth.forgot_password": "Password dimenticata", + "auth.forgot_password_link": "Hai dimenticato la password?", + "auth.forgot_password_heading": "Recupero password", "auth.forgot_password_send_email": "Invia email per reimpostare la password", "auth.register_now": "Registrati", "auth.logout": "Esci", @@ -39,4 +40,4 @@ "resource.delete_configurations": "Elimina definitivamente tutti i file di configurazione dal server.", "database.delete_backups_locally": "Tutti i backup verranno eliminati definitivamente dall'archiviazione locale.", "warning.sslipdomain": "La tua configurazione è stata salvata, ma il dominio sslip con https NON è raccomandato, poiché i server di Let's Encrypt con questo dominio pubblico hanno limitazioni di frequenza (la convalida del certificato SSL fallirà).

      Utilizza invece il tuo dominio personale." -} +} \ No newline at end of file diff --git a/lang/ja.json b/lang/ja.json index 87d87d99b..05987e7ce 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -10,7 +10,8 @@ "auth.login.infomaniak": "Infomaniakでログイン", "auth.already_registered": "すでに登録済みですか?", "auth.confirm_password": "パスワードを確認", - "auth.forgot_password": "パスワードを忘れた", + "auth.forgot_password_link": "パスワードをお忘れですか?", + "auth.forgot_password_heading": "パスワードの再設定", "auth.forgot_password_send_email": "パスワードリセットメールを送信", "auth.register_now": "今すぐ登録", "auth.logout": "ログアウト", @@ -30,4 +31,4 @@ "input.recovery_code": "リカバリーコード", "button.save": "保存", "repository.url": "
      公開リポジトリの場合はhttps://...を使用してください。
      プライベートリポジトリの場合はgit@...を使用してください。

      https://github.com/coollabsio/coolify-examples mainブランチが選択されます
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastifyブランチが選択されます。
      https://gitea.com/sedlav/expressjs.git mainブランチが選択されます。
      https://gitlab.com/andrasbacsai/nodejs-example.git mainブランチが選択されます。" -} +} \ No newline at end of file diff --git a/lang/no.json b/lang/no.json index a84f6aa6c..967bdf606 100644 --- a/lang/no.json +++ b/lang/no.json @@ -11,7 +11,8 @@ "auth.login.infomaniak": "Logg inn med Infomaniak", "auth.already_registered": "Allerede registrert?", "auth.confirm_password": "Bekreft passord", - "auth.forgot_password": "Glemt passord", + "auth.forgot_password_link": "Glemt passord?", + "auth.forgot_password_heading": "Gjenoppretting av passord", "auth.forgot_password_send_email": "Send e-post for tilbakestilling av passord", "auth.register_now": "Registrer deg", "auth.logout": "Logg ut", @@ -39,4 +40,4 @@ "resource.delete_configurations": "Slett alle konfigurasjonsfiler fra serveren permanent.", "database.delete_backups_locally": "Alle sikkerhetskopier vil bli slettet permanent fra lokal lagring.", "warning.sslipdomain": "Konfigurasjonen din er lagret, men sslip-domene med https er IKKE anbefalt, fordi Let's Encrypt-servere med dette offentlige domenet er hastighetsbegrenset (SSL-sertifikatvalidering vil mislykkes).

      Bruk ditt eget domene i stedet." -} +} \ No newline at end of file diff --git a/lang/pl.json b/lang/pl.json new file mode 100644 index 000000000..bcd8e2393 --- /dev/null +++ b/lang/pl.json @@ -0,0 +1,44 @@ +{ + "auth.login": "Zaloguj", + "auth.login.authentik": "Zaloguj się przez Authentik", + "auth.login.azure": "Zaloguj się przez Microsoft", + "auth.login.bitbucket": "Zaloguj się przez Bitbucket", + "auth.login.clerk": "Zaloguj się przez Clerk", + "auth.login.discord": "Zaloguj się przez Discord", + "auth.login.github": "Zaloguj się przez GitHub", + "auth.login.gitlab": "Zaloguj się przez Gitlab", + "auth.login.google": "Zaloguj się przez Google", + "auth.login.infomaniak": "Zaloguj się przez Infomaniak", + "auth.login.zitadel": "Zaloguj się przez Zitadel", + "auth.already_registered": "Już zarejestrowany?", + "auth.confirm_password": "Potwierdź hasło", + "auth.forgot_password_link": "Zapomniałeś hasło?", + "auth.forgot_password_heading": "Odzyskiwanie hasła", + "auth.forgot_password_send_email": "Wyślij email resetujący hasło", + "auth.register_now": "Zarejestruj", + "auth.logout": "Wyloguj", + "auth.register": "Zarejestruj", + "auth.registration_disabled": "Rejestracja jest wyłączona. Skontaktuj się z administratorem.", + "auth.reset_password": "Zresetuj hasło", + "auth.failed": "Podane dane nie zgadzają się z naszymi rekordami.", + "auth.failed.callback": "Nie udało się przeprocesować callbacku od dostawcy logowania.", + "auth.failed.password": "Podane hasło jest nieprawidłowe.", + "auth.failed.email": "Nie znaleziono użytkownika z takim adresem email.", + "auth.throttle": "Zbyt wiele prób logowania. Spróbuj ponownie za :seconds sekund.", + "input.name": "Nazwa", + "input.email": "Email", + "input.password": "Hasło", + "input.password.again": "Hasło ponownie", + "input.code": "Jednorazowy kod", + "input.recovery_code": "Kod odzyskiwania", + "button.save": "Zapisz", + "repository.url": "Przykłady
      Dla publicznych repozytoriów użyj https://....
      Dla prywatnych repozytoriów, użyj git@....

      https://github.com/coollabsio/coolify-examples - zostanie wybrany branch main
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify - zostanie wybrany branch nodejs-fastify
      https://gitea.com/sedlav/expressjs.git - zostanie wybrany branch main
      https://gitlab.com/andrasbacsai/nodejs-example.git - zostanie wybrany branch main", + "service.stop": "Ten serwis zostanie zatrzymany.", + "resource.docker_cleanup": "Uruchom Docker Cleanup (usunie nieużywane obrazy i cache buildera).", + "resource.non_persistent": "Wszystkie nietrwałe dane zostaną usunięte.", + "resource.delete_volumes": "Trwale usuń wszystkie wolumeny powiązane z tym zasobem.", + "resource.delete_connected_networks": "Trwale usuń wszystkie niepredefiniowane sieci powiązane z tym zasobem.", + "resource.delete_configurations": "Trwale usuń wszystkie pliki konfiguracyjne z serwera.", + "database.delete_backups_locally": "Wszystkie backupy zostaną trwale usunięte z lokalnej pamięci.", + "warning.sslipdomain": "Twoja konfiguracja została zapisana, lecz domena sslip z https jest NIEZALECANA, ponieważ serwery Let's Encrypt z tą publiczną domeną są pod rate limitem (walidacja certyfikatu SSL certificate się nie powiedzie).

      Lepiej użyj własnej domeny." +} \ No newline at end of file diff --git a/lang/pt-br.json b/lang/pt-br.json index c3a102995..f3ebb6c69 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -11,7 +11,8 @@ "auth.login.infomaniak": "Entrar com Infomaniak", "auth.already_registered": "Já tem uma conta?", "auth.confirm_password": "Confirmar senha", - "auth.forgot_password": "Esqueceu a senha", + "auth.forgot_password_link": "Esqueceu a senha?", + "auth.forgot_password_heading": "Recuperação de senha", "auth.forgot_password_send_email": "Enviar e-mail para redefinir senha", "auth.register_now": "Cadastre-se", "auth.logout": "Sair", @@ -39,4 +40,4 @@ "resource.delete_configurations": "Excluir permanentemente todos os arquivos de configuração do servidor.", "database.delete_backups_locally": "Todos os backups serão excluídos permanentemente do armazenamento local.", "warning.sslipdomain": "Sua configuração foi salva, mas o domínio sslip com https NÃO é recomendado, porque os servidores do Let's Encrypt com este domínio público têm limitação de taxa (a validação do certificado SSL falhará).

      Use seu próprio domínio em vez disso." -} +} \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index 80ff8c146..08ad19df3 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -10,7 +10,8 @@ "auth.login.infomaniak": "Entrar com Infomaniak", "auth.already_registered": "Já tem uma conta?", "auth.confirm_password": "Confirmar senha", - "auth.forgot_password": "Esqueceu a senha?", + "auth.forgot_password_link": "Esqueceu a senha?", + "auth.forgot_password_heading": "Recuperação de senha", "auth.forgot_password_send_email": "Enviar e-mail de redefinição de senha", "auth.register_now": "Cadastrar-se", "auth.logout": "Sair", @@ -30,4 +31,4 @@ "input.recovery_code": "Código de recuperação", "button.save": "Salvar", "repository.url": "Exemplos
      Para repositórios públicos, use https://....
      Para repositórios privados, use git@....

      https://github.com/coollabsio/coolify-examples a branch main será selecionada
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify a branch nodejs-fastify será selecionada.
      https://gitea.com/sedlav/expressjs.git a branch main será selecionada.
      https://gitlab.com/andrasbacsai/nodejs-example.git a branch main será selecionada." -} +} \ No newline at end of file diff --git a/lang/ro.json b/lang/ro.json index 5588ea6f4..18028d087 100644 --- a/lang/ro.json +++ b/lang/ro.json @@ -10,7 +10,8 @@ "auth.login.infomaniak": "Autentificare prin Infomaniak", "auth.already_registered": "Sunteți deja înregistrat?", "auth.confirm_password": "Confirmați parola", - "auth.forgot_password": "Ați uitat parola", + "auth.forgot_password_link": "Ați uitat parola?", + "auth.forgot_password_heading": "Recuperare parolă", "auth.forgot_password_send_email": "Trimiteți e-mail-ul pentru resetarea parolei", "auth.register_now": "Înregistrare", "auth.logout": "Deconectare", @@ -37,4 +38,4 @@ "resource.delete_connected_networks": "Ștergeți definitiv toate rețelele non-predefinite asociate cu această resursă.", "resource.delete_configurations": "Ștergeți definitiv toate fișierele de configurare de pe server.", "database.delete_backups_locally": "Toate copiile de rezervă vor fi șterse definitiv din stocarea locală." -} +} \ No newline at end of file diff --git a/lang/tr.json b/lang/tr.json index 74f693dc9..e3f34aa14 100644 --- a/lang/tr.json +++ b/lang/tr.json @@ -10,7 +10,8 @@ "auth.login.infomaniak": "Infomaniak ile Giriş Yap", "auth.already_registered": "Zaten kayıtlı mısınız?", "auth.confirm_password": "Şifreyi Onayla", - "auth.forgot_password": "Şifremi Unuttum", + "auth.forgot_password_link": "Şifrenizi mi unuttunuz?", + "auth.forgot_password_heading": "Şifre Kurtarma", "auth.forgot_password_send_email": "Şifre sıfırlama e-postası gönder", "auth.register_now": "Kayıt Ol", "auth.logout": "Çıkış Yap", @@ -38,4 +39,4 @@ "resource.delete_configurations": "Sunucudaki tüm yapılandırma dosyaları kalıcı olarak silinecek.", "database.delete_backups_locally": "Tüm yedekler yerel depolamadan kalıcı olarak silinecek.", "warning.sslipdomain": "Yapılandırmanız kaydedildi, ancak sslip domain ile https ÖNERİLMEZ, çünkü Let's Encrypt sunucuları bu genel domain ile sınırlandırılmıştır (SSL sertifikası doğrulaması başarısız olur).

      Bunun yerine kendi domaininizi kullanın." -} +} \ No newline at end of file diff --git a/lang/vi.json b/lang/vi.json index 46edac599..76e380477 100644 --- a/lang/vi.json +++ b/lang/vi.json @@ -10,7 +10,8 @@ "auth.login.infomaniak": "Đăng Nhập Bằng Infomaniak", "auth.already_registered": "Đã đăng ký?", "auth.confirm_password": "Nhập lại mật khẩu", - "auth.forgot_password": "Quên mật khẩu", + "auth.forgot_password_link": "Quên mật khẩu?", + "auth.forgot_password_heading": "Khôi phục mật khẩu", "auth.forgot_password_send_email": "Gửi email đặt lại mật khẩu", "auth.register_now": "Đăng ký ngay", "auth.logout": "Đăng xuất", @@ -30,4 +31,4 @@ "input.recovery_code": "Mã khôi phục", "button.save": "Lưu", "repository.url": "Ví dụ
      Với repo công khai, sử dụng https://....
      Với repo riêng tư, sử dụng git@....

      https://github.com/coollabsio/coolify-examples nhánh chính sẽ được chọn
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nhánh nodejs-fastify sẽ được chọn.
      https://gitea.com/sedlav/expressjs.git nhánh chính sẽ được chọn.
      https://gitlab.com/andrasbacsai/nodejs-example.git nhánh chính sẽ được chọn." -} +} \ No newline at end of file diff --git a/lang/zh-cn.json b/lang/zh-cn.json index d46c71e07..530621ee1 100644 --- a/lang/zh-cn.json +++ b/lang/zh-cn.json @@ -10,7 +10,8 @@ "auth.login.infomaniak": "使用 Infomaniak 登录", "auth.already_registered": "已经注册?", "auth.confirm_password": "确认密码", - "auth.forgot_password": "忘记密码", + "auth.forgot_password_link": "忘记密码?", + "auth.forgot_password_heading": "密码找回", "auth.forgot_password_send_email": "发送密码重置邮件", "auth.register_now": "注册", "auth.logout": "退出登录", @@ -30,4 +31,4 @@ "input.recovery_code": "恢复码", "button.save": "保存", "repository.url": "示例
      对于公共代码仓库,请使用 https://...
      对于私有代码仓库,请使用 git@...

      https://github.com/coollabsio/coolify-examples main 分支将被选择
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify 分支将被选择。
      https://gitea.com/sedlav/expressjs.git main 分支将被选择。
      https://gitlab.com/andrasbacsai/nodejs-example.git main 分支将被选择" -} +} \ No newline at end of file diff --git a/lang/zh-tw.json b/lang/zh-tw.json index c0784c7b7..aa078104b 100644 --- a/lang/zh-tw.json +++ b/lang/zh-tw.json @@ -10,7 +10,8 @@ "auth.login.infomaniak": "使用 Infomaniak 登入", "auth.already_registered": "已經註冊?", "auth.confirm_password": "確認密碼", - "auth.forgot_password": "忘記密碼", + "auth.forgot_password_link": "忘記密碼?", + "auth.forgot_password_heading": "密碼找回", "auth.forgot_password_send_email": "發送重設密碼電郵", "auth.register_now": "註冊", "auth.logout": "登出", @@ -30,4 +31,4 @@ "input.recovery_code": "恢復碼", "button.save": "儲存", "repository.url": "例子
      對於公共代碼倉庫,請使用 https://...
      對於私有代碼倉庫,請使用 git@...

      https://github.com/coollabsio/coolify-examples main 分支將被選擇
      https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify 分支將被選擇。
      https://gitea.com/sedlav/expressjs.git main 分支將被選擇。
      https://gitlab.com/andrasbacsai/nodejs-example.git main 分支將被選擇。" -} +} \ No newline at end of file diff --git a/openapi.json b/openapi.json index 791828aed..2b0a81c6e 100644 --- a/openapi.json +++ b/openapi.json @@ -357,6 +357,10 @@ "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -385,6 +389,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -709,6 +767,10 @@ "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -737,6 +799,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -1061,6 +1177,10 @@ "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -1089,6 +1209,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -1342,6 +1516,10 @@ "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -1370,6 +1548,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -1606,6 +1838,10 @@ "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -1634,6 +1870,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -1709,6 +1999,10 @@ "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -1737,6 +2031,60 @@ }, "400": { "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -2175,6 +2523,10 @@ "connect_to_docker_network": { "type": "boolean", "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." } }, "type": "object" @@ -2206,6 +2558,60 @@ }, "404": { "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } } }, "security": [ @@ -2367,10 +2773,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -2464,10 +2866,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -2566,10 +2964,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -5196,6 +5590,190 @@ ] } }, + "\/projects\/{uuid}\/environments": { + "get": { + "tags": [ + "Projects" + ], + "summary": "List Environments", + "description": "List all environments in a project.", + "operationId": "get-environments", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of environments", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Environment" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "description": "Project not found." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Projects" + ], + "summary": "Create Environment", + "description": "Create environment in project.", + "operationId": "create-environment", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Environment created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Environment created.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string", + "example": "env123", + "description": "The UUID of the environment." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "description": "Project not found." + }, + "409": { + "description": "Environment with this name already exists." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/projects\/{uuid}\/environments\/{environment_name_or_uuid}": { + "delete": { + "tags": [ + "Projects" + ], + "summary": "Delete Environment", + "description": "Delete environment by name or UUID. Environment must be empty.", + "operationId": "delete-environment", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Project UUID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "environment_name_or_uuid", + "in": "path", + "description": "Environment name or UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Environment deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Environment deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "description": "Environment has resources, so it cannot be deleted." + }, + "404": { + "description": "Project or environment not found." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/resources": { "get": { "tags": [ @@ -6412,13 +6990,6 @@ "content": { "application\/json": { "schema": { - "required": [ - "server_uuid", - "project_uuid", - "environment_name", - "environment_uuid", - "docker_compose_raw" - ], "properties": { "name": { "type": "string", @@ -6596,10 +7167,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -6693,10 +7260,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -6795,10 +7358,6 @@ "type": "boolean", "description": "The flag to indicate if the environment variable is used in preview deployments." }, - "is_build_time": { - "type": "boolean", - "description": "The flag to indicate if the environment variable is used in build time." - }, "is_literal": { "type": "boolean", "description": "The flag to indicate if the environment variable is a literal, nothing espaced." @@ -7792,9 +8351,6 @@ "resourceable_id": { "type": "integer" }, - "is_build_time": { - "type": "boolean" - }, "is_literal": { "type": "boolean" }, @@ -7804,6 +8360,12 @@ "is_preview": { "type": "boolean" }, + "is_runtime": { + "type": "boolean" + }, + "is_buildtime": { + "type": "boolean" + }, "is_shared": { "type": "boolean" }, @@ -8026,6 +8588,9 @@ "is_swarm_worker": { "type": "boolean" }, + "is_terminal_enabled": { + "type": "boolean" + }, "is_usable": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index 3f2fa1c59..9529fcf87 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -262,6 +262,9 @@ paths: connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -276,6 +279,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -515,6 +528,9 @@ paths: connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -529,6 +545,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -768,6 +794,9 @@ paths: connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -782,6 +811,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -968,6 +1007,9 @@ paths: connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -982,6 +1024,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -1159,6 +1211,9 @@ paths: connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -1173,6 +1228,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -1230,6 +1295,9 @@ paths: connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '201': @@ -1244,6 +1312,16 @@ paths: $ref: '#/components/responses/401' '400': $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -1560,6 +1638,9 @@ paths: connect_to_docker_network: type: boolean description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' type: object responses: '200': @@ -1576,6 +1657,16 @@ paths: $ref: '#/components/responses/400' '404': $ref: '#/components/responses/404' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object security: - bearerAuth: [] @@ -1687,9 +1778,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -1752,9 +1840,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -1810,7 +1895,7 @@ paths: properties: data: type: array - items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } type: object responses: '201': @@ -3570,6 +3655,124 @@ paths: security: - bearerAuth: [] + '/projects/{uuid}/environments': + get: + tags: + - Projects + summary: 'List Environments' + description: 'List all environments in a project.' + operationId: get-environments + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + responses: + '200': + description: 'List of environments' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Environment' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + description: 'Project not found.' + security: + - + bearerAuth: [] + post: + tags: + - Projects + summary: 'Create Environment' + description: 'Create environment in project.' + operationId: create-environment + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + requestBody: + description: 'Environment created.' + required: true + content: + application/json: + schema: + properties: + name: + type: string + description: 'The name of the environment.' + type: object + responses: + '201': + description: 'Environment created.' + content: + application/json: + schema: + properties: + uuid: { type: string, example: env123, description: 'The UUID of the environment.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + description: 'Project not found.' + '409': + description: 'Environment with this name already exists.' + security: + - + bearerAuth: [] + '/projects/{uuid}/environments/{environment_name_or_uuid}': + delete: + tags: + - Projects + summary: 'Delete Environment' + description: 'Delete environment by name or UUID. Environment must be empty.' + operationId: delete-environment + parameters: + - + name: uuid + in: path + description: 'Project UUID' + required: true + schema: + type: string + - + name: environment_name_or_uuid + in: path + description: 'Environment name or UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Environment deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Environment deleted.' } + type: object + '401': + $ref: '#/components/responses/401' + '400': + description: 'Environment has resources, so it cannot be deleted.' + '404': + description: 'Project or environment not found.' + security: + - + bearerAuth: [] /resources: get: tags: @@ -4289,12 +4492,6 @@ paths: content: application/json: schema: - required: - - server_uuid - - project_uuid - - environment_name - - environment_uuid - - docker_compose_raw properties: name: type: string @@ -4412,9 +4609,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -4477,9 +4671,6 @@ paths: is_preview: type: boolean description: 'The flag to indicate if the environment variable is used in preview deployments.' - is_build_time: - type: boolean - description: 'The flag to indicate if the environment variable is used in build time.' is_literal: type: boolean description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' @@ -4535,7 +4726,7 @@ paths: properties: data: type: array - items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_build_time: { type: boolean, description: 'The flag to indicate if the environment variable is used in build time.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } + items: { properties: { key: { type: string, description: 'The key of the environment variable.' }, value: { type: string, description: 'The value of the environment variable.' }, is_preview: { type: boolean, description: 'The flag to indicate if the environment variable is used in preview deployments.' }, is_literal: { type: boolean, description: 'The flag to indicate if the environment variable is a literal, nothing espaced.' }, is_multiline: { type: boolean, description: 'The flag to indicate if the environment variable is multiline.' }, is_shown_once: { type: boolean, description: "The flag to indicate if the environment variable's value is shown on the UI." } }, type: object } type: object responses: '201': @@ -5214,14 +5405,16 @@ components: type: string resourceable_id: type: integer - is_build_time: - type: boolean is_literal: type: boolean is_multiline: type: boolean is_preview: type: boolean + is_runtime: + type: boolean + is_buildtime: + type: boolean is_shared: type: boolean is_shown_once: @@ -5377,6 +5570,8 @@ components: type: boolean is_swarm_worker: type: boolean + is_terminal_enabled: + type: boolean is_usable: type: boolean logdrain_axiom_api_key: diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 8d362115e..fd5dccaf0 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,19 +1,19 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.420.2" + "version": "4.0.0-beta.428" }, "nightly": { - "version": "4.0.0-beta.420.3" + "version": "4.0.0-beta.429" }, "helper": { - "version": "1.0.8" + "version": "1.0.11" }, "realtime": { - "version": "1.0.9" + "version": "1.0.10" }, "sentinel": { - "version": "0.0.15" + "version": "0.0.16" } } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 10489a7d4..56e48288c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "pusher-js": "8.4.0", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.10", - "vite": "6.3.5", + "vite": "6.3.6", "vue": "3.5.16" } }, @@ -90,9 +90,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", - "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -104,9 +104,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", - "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", "cpu": [ "ppc64" ], @@ -121,9 +121,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz", - "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", "cpu": [ "arm" ], @@ -138,9 +138,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz", - "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", "cpu": [ "arm64" ], @@ -155,9 +155,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz", - "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", "cpu": [ "x64" ], @@ -172,9 +172,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", - "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", "cpu": [ "arm64" ], @@ -189,9 +189,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz", - "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", "cpu": [ "x64" ], @@ -206,9 +206,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz", - "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", "cpu": [ "arm64" ], @@ -223,9 +223,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz", - "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", "cpu": [ "x64" ], @@ -240,9 +240,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz", - "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", "cpu": [ "arm" ], @@ -257,9 +257,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz", - "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", "cpu": [ "arm64" ], @@ -274,9 +274,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz", - "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", "cpu": [ "ia32" ], @@ -291,9 +291,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz", - "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", "cpu": [ "loong64" ], @@ -308,9 +308,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz", - "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", "cpu": [ "mips64el" ], @@ -325,9 +325,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz", - "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", "cpu": [ "ppc64" ], @@ -342,9 +342,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz", - "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", "cpu": [ "riscv64" ], @@ -359,9 +359,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz", - "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", "cpu": [ "s390x" ], @@ -376,9 +376,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz", - "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", "cpu": [ "x64" ], @@ -393,9 +393,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz", - "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", "cpu": [ "arm64" ], @@ -410,9 +410,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz", - "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", "cpu": [ "x64" ], @@ -427,9 +427,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz", - "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", "cpu": [ "arm64" ], @@ -444,9 +444,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz", - "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", "cpu": [ "x64" ], @@ -461,9 +461,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz", - "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", "cpu": [ "arm64" ], @@ -478,9 +478,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz", - "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", "cpu": [ "x64" ], @@ -495,9 +495,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz", - "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", "cpu": [ "arm64" ], @@ -512,9 +512,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz", - "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", "cpu": [ "ia32" ], @@ -529,9 +529,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz", - "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", "cpu": [ "x64" ], @@ -546,9 +546,9 @@ } }, "node_modules/@ioredis/commands": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.0.tgz", + "integrity": "sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==", "license": "MIT" }, "node_modules/@isaacs/fs-minipass": { @@ -604,9 +604,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", - "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", + "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", "cpu": [ "arm" ], @@ -618,9 +618,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", - "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", + "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", "cpu": [ "arm64" ], @@ -632,9 +632,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", - "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", + "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", "cpu": [ "arm64" ], @@ -646,9 +646,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", - "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", + "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", "cpu": [ "x64" ], @@ -660,9 +660,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", - "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", + "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", "cpu": [ "arm64" ], @@ -674,9 +674,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", - "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", + "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", "cpu": [ "x64" ], @@ -688,9 +688,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", - "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", + "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", "cpu": [ "arm" ], @@ -702,9 +702,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", - "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", + "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", "cpu": [ "arm" ], @@ -716,9 +716,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", - "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", + "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", "cpu": [ "arm64" ], @@ -730,9 +730,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", - "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", + "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", "cpu": [ "arm64" ], @@ -744,9 +744,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", - "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", + "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", "cpu": [ "loong64" ], @@ -757,10 +757,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", - "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", + "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", "cpu": [ "ppc64" ], @@ -772,9 +772,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", - "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", + "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", "cpu": [ "riscv64" ], @@ -786,9 +786,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", - "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", + "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", "cpu": [ "riscv64" ], @@ -800,9 +800,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", - "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", + "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", "cpu": [ "s390x" ], @@ -814,9 +814,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", - "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", + "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", "cpu": [ "x64" ], @@ -828,9 +828,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", - "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", + "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", "cpu": [ "x64" ], @@ -842,9 +842,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", - "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", + "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", "cpu": [ "arm64" ], @@ -856,9 +856,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", - "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", + "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", "cpu": [ "ia32" ], @@ -870,9 +870,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", - "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", + "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", "cpu": [ "x64" ], @@ -1131,6 +1131,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.10", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz", @@ -1623,9 +1683,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", - "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1636,32 +1696,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.6", - "@esbuild/android-arm": "0.25.6", - "@esbuild/android-arm64": "0.25.6", - "@esbuild/android-x64": "0.25.6", - "@esbuild/darwin-arm64": "0.25.6", - "@esbuild/darwin-x64": "0.25.6", - "@esbuild/freebsd-arm64": "0.25.6", - "@esbuild/freebsd-x64": "0.25.6", - "@esbuild/linux-arm": "0.25.6", - "@esbuild/linux-arm64": "0.25.6", - "@esbuild/linux-ia32": "0.25.6", - "@esbuild/linux-loong64": "0.25.6", - "@esbuild/linux-mips64el": "0.25.6", - "@esbuild/linux-ppc64": "0.25.6", - "@esbuild/linux-riscv64": "0.25.6", - "@esbuild/linux-s390x": "0.25.6", - "@esbuild/linux-x64": "0.25.6", - "@esbuild/netbsd-arm64": "0.25.6", - "@esbuild/netbsd-x64": "0.25.6", - "@esbuild/openbsd-arm64": "0.25.6", - "@esbuild/openbsd-x64": "0.25.6", - "@esbuild/openharmony-arm64": "0.25.6", - "@esbuild/sunos-x64": "0.25.6", - "@esbuild/win32-arm64": "0.25.6", - "@esbuild/win32-ia32": "0.25.6", - "@esbuild/win32-x64": "0.25.6" + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" } }, "node_modules/estree-walker": { @@ -1687,9 +1747,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "dev": true, "funding": [ { @@ -1875,9 +1935,9 @@ } }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "dev": true, "license": "MIT", "bin": { @@ -2397,9 +2457,9 @@ } }, "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "dev": true, "license": "MIT", "peer": true, @@ -2429,9 +2489,9 @@ } }, "node_modules/rollup": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", - "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", + "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", "dev": true, "license": "MIT", "dependencies": { @@ -2445,26 +2505,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.45.1", - "@rollup/rollup-android-arm64": "4.45.1", - "@rollup/rollup-darwin-arm64": "4.45.1", - "@rollup/rollup-darwin-x64": "4.45.1", - "@rollup/rollup-freebsd-arm64": "4.45.1", - "@rollup/rollup-freebsd-x64": "4.45.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", - "@rollup/rollup-linux-arm-musleabihf": "4.45.1", - "@rollup/rollup-linux-arm64-gnu": "4.45.1", - "@rollup/rollup-linux-arm64-musl": "4.45.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", - "@rollup/rollup-linux-riscv64-gnu": "4.45.1", - "@rollup/rollup-linux-riscv64-musl": "4.45.1", - "@rollup/rollup-linux-s390x-gnu": "4.45.1", - "@rollup/rollup-linux-x64-gnu": "4.45.1", - "@rollup/rollup-linux-x64-musl": "4.45.1", - "@rollup/rollup-win32-arm64-msvc": "4.45.1", - "@rollup/rollup-win32-ia32-msvc": "4.45.1", - "@rollup/rollup-win32-x64-msvc": "4.45.1", + "@rollup/rollup-android-arm-eabi": "4.46.2", + "@rollup/rollup-android-arm64": "4.46.2", + "@rollup/rollup-darwin-arm64": "4.46.2", + "@rollup/rollup-darwin-x64": "4.46.2", + "@rollup/rollup-freebsd-arm64": "4.46.2", + "@rollup/rollup-freebsd-x64": "4.46.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", + "@rollup/rollup-linux-arm-musleabihf": "4.46.2", + "@rollup/rollup-linux-arm64-gnu": "4.46.2", + "@rollup/rollup-linux-arm64-musl": "4.46.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", + "@rollup/rollup-linux-ppc64-gnu": "4.46.2", + "@rollup/rollup-linux-riscv64-gnu": "4.46.2", + "@rollup/rollup-linux-riscv64-musl": "4.46.2", + "@rollup/rollup-linux-s390x-gnu": "4.46.2", + "@rollup/rollup-linux-x64-gnu": "4.46.2", + "@rollup/rollup-linux-x64-musl": "4.46.2", + "@rollup/rollup-win32-arm64-msvc": "4.46.2", + "@rollup/rollup-win32-ia32-msvc": "4.46.2", + "@rollup/rollup-win32-x64-msvc": "4.46.2", "fsevents": "~2.3.2" } }, @@ -2635,9 +2695,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 10ec71415..e29c5e8e6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "pusher-js": "8.4.0", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.10", - "vite": "6.3.5", + "vite": "6.3.6", "vue": "3.5.16" }, "dependencies": { diff --git a/public/js/purify.min.js b/public/js/purify.min.js new file mode 100644 index 000000000..73df78d60 --- /dev/null +++ b/public/js/purify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.2.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.6/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=R(Array.prototype.forEach),m=R(Array.prototype.lastIndexOf),p=R(Array.prototype.pop),f=R(Array.prototype.push),d=R(Array.prototype.splice),h=R(String.prototype.toLowerCase),g=R(String.prototype.toString),T=R(String.prototype.match),y=R(String.prototype.replace),E=R(String.prototype.indexOf),A=R(String.prototype.trim),_=R(Object.prototype.hasOwnProperty),S=R(RegExp.prototype.test),b=(N=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:h;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function O(e){for(let t=0;t/gm),G=a(/\$\{[\w\W]*/gm),Y=a(/^data-[\-\w.\u00B7-\uFFFF]+$/),j=a(/^aria-[\-\w]+$/),X=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),q=a(/^(?:\w+script|data):/i),$=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),K=a(/^html$/i),V=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var Z=Object.freeze({__proto__:null,ARIA_ATTR:j,ATTR_WHITESPACE:$,CUSTOM_ELEMENT:V,DATA_ATTR:Y,DOCTYPE_NAME:K,ERB_EXPR:W,IS_ALLOWED_URI:X,IS_SCRIPT_OR_DATA:q,MUSTACHE_EXPR:B,TMPLIT_EXPR:G});const J=1,Q=3,ee=7,te=8,ne=9,oe=function(){return"undefined"==typeof window?null:window};var re=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:oe();const o=e=>t(e);if(o.version="3.2.6",o.removed=[],!n||!n.document||n.document.nodeType!==ne||!n.Element)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:R,Element:O,NodeFilter:B,NamedNodeMap:W=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:G,DOMParser:Y,trustedTypes:j}=n,q=O.prototype,$=v(q,"cloneNode"),V=v(q,"remove"),re=v(q,"nextSibling"),ie=v(q,"childNodes"),ae=v(q,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let le,ce="";const{implementation:se,createNodeIterator:ue,createDocumentFragment:me,getElementsByTagName:pe}=r,{importNode:fe}=a;let de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof e&&"function"==typeof ae&&se&&void 0!==se.createHTMLDocument;const{MUSTACHE_EXPR:he,ERB_EXPR:ge,TMPLIT_EXPR:Te,DATA_ATTR:ye,ARIA_ATTR:Ee,IS_SCRIPT_OR_DATA:Ae,ATTR_WHITESPACE:_e,CUSTOM_ELEMENT:Se}=Z;let{IS_ALLOWED_URI:be}=Z,Ne=null;const Re=w({},[...L,...C,...x,...M,...U]);let we=null;const Oe=w({},[...z,...P,...H,...F]);let De=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ve=null,Le=null,Ce=!0,xe=!0,Ie=!1,Me=!0,ke=!1,Ue=!0,ze=!1,Pe=!1,He=!1,Fe=!1,Be=!1,We=!1,Ge=!0,Ye=!1,je=!0,Xe=!1,qe={},$e=null;const Ke=w({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ve=null;const Ze=w({},["audio","video","img","source","image","track"]);let Je=null;const Qe=w({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),et="http://www.w3.org/1998/Math/MathML",tt="http://www.w3.org/2000/svg",nt="http://www.w3.org/1999/xhtml";let ot=nt,rt=!1,it=null;const at=w({},[et,tt,nt],g);let lt=w({},["mi","mo","mn","ms","mtext"]),ct=w({},["annotation-xml"]);const st=w({},["title","style","font","a","script"]);let ut=null;const mt=["application/xhtml+xml","text/html"];let pt=null,ft=null;const dt=r.createElement("form"),ht=function(e){return e instanceof RegExp||e instanceof Function},gt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ft||ft!==e){if(e&&"object"==typeof e||(e={}),e=D(e),ut=-1===mt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,pt="application/xhtml+xml"===ut?g:h,Ne=_(e,"ALLOWED_TAGS")?w({},e.ALLOWED_TAGS,pt):Re,we=_(e,"ALLOWED_ATTR")?w({},e.ALLOWED_ATTR,pt):Oe,it=_(e,"ALLOWED_NAMESPACES")?w({},e.ALLOWED_NAMESPACES,g):at,Je=_(e,"ADD_URI_SAFE_ATTR")?w(D(Qe),e.ADD_URI_SAFE_ATTR,pt):Qe,Ve=_(e,"ADD_DATA_URI_TAGS")?w(D(Ze),e.ADD_DATA_URI_TAGS,pt):Ze,$e=_(e,"FORBID_CONTENTS")?w({},e.FORBID_CONTENTS,pt):Ke,ve=_(e,"FORBID_TAGS")?w({},e.FORBID_TAGS,pt):D({}),Le=_(e,"FORBID_ATTR")?w({},e.FORBID_ATTR,pt):D({}),qe=!!_(e,"USE_PROFILES")&&e.USE_PROFILES,Ce=!1!==e.ALLOW_ARIA_ATTR,xe=!1!==e.ALLOW_DATA_ATTR,Ie=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Me=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,ke=e.SAFE_FOR_TEMPLATES||!1,Ue=!1!==e.SAFE_FOR_XML,ze=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,Be=e.RETURN_DOM_FRAGMENT||!1,We=e.RETURN_TRUSTED_TYPE||!1,He=e.FORCE_BODY||!1,Ge=!1!==e.SANITIZE_DOM,Ye=e.SANITIZE_NAMED_PROPS||!1,je=!1!==e.KEEP_CONTENT,Xe=e.IN_PLACE||!1,be=e.ALLOWED_URI_REGEXP||X,ot=e.NAMESPACE||nt,lt=e.MATHML_TEXT_INTEGRATION_POINTS||lt,ct=e.HTML_INTEGRATION_POINTS||ct,De=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(De.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(De.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(De.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),ke&&(xe=!1),Be&&(Fe=!0),qe&&(Ne=w({},U),we=[],!0===qe.html&&(w(Ne,L),w(we,z)),!0===qe.svg&&(w(Ne,C),w(we,P),w(we,F)),!0===qe.svgFilters&&(w(Ne,x),w(we,P),w(we,F)),!0===qe.mathMl&&(w(Ne,M),w(we,H),w(we,F))),e.ADD_TAGS&&(Ne===Re&&(Ne=D(Ne)),w(Ne,e.ADD_TAGS,pt)),e.ADD_ATTR&&(we===Oe&&(we=D(we)),w(we,e.ADD_ATTR,pt)),e.ADD_URI_SAFE_ATTR&&w(Je,e.ADD_URI_SAFE_ATTR,pt),e.FORBID_CONTENTS&&($e===Ke&&($e=D($e)),w($e,e.FORBID_CONTENTS,pt)),je&&(Ne["#text"]=!0),ze&&w(Ne,["html","head","body"]),Ne.table&&(w(Ne,["tbody"]),delete ve.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');le=e.TRUSTED_TYPES_POLICY,ce=le.createHTML("")}else void 0===le&&(le=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(j,c)),null!==le&&"string"==typeof ce&&(ce=le.createHTML(""));i&&i(e),ft=e}},Tt=w({},[...C,...x,...I]),yt=w({},[...M,...k]),Et=function(e){f(o.removed,{element:e});try{ae(e).removeChild(e)}catch(t){V(e)}},At=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(Fe||Be)try{Et(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},_t=function(e){let t=null,n=null;if(He)e=""+e;else{const t=T(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===ut&&ot===nt&&(e=''+e+"");const o=le?le.createHTML(e):e;if(ot===nt)try{t=(new Y).parseFromString(o,ut)}catch(e){}if(!t||!t.documentElement){t=se.createDocument(ot,"template",null);try{t.documentElement.innerHTML=rt?ce:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),ot===nt?pe.call(t,ze?"html":"body")[0]:ze?t.documentElement:i},St=function(e){return ue.call(e.ownerDocument||e,e,B.SHOW_ELEMENT|B.SHOW_COMMENT|B.SHOW_TEXT|B.SHOW_PROCESSING_INSTRUCTION|B.SHOW_CDATA_SECTION,null)},bt=function(e){return e instanceof G&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof W)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof R&&e instanceof R};function Rt(e,t,n){u(e,(e=>{e.call(o,t,n,ft)}))}const wt=function(e){let t=null;if(Rt(de.beforeSanitizeElements,e,null),bt(e))return Et(e),!0;const n=pt(e.nodeName);if(Rt(de.uponSanitizeElement,e,{tagName:n,allowedTags:Ne}),Ue&&e.hasChildNodes()&&!Nt(e.firstElementChild)&&S(/<[/\w!]/g,e.innerHTML)&&S(/<[/\w!]/g,e.textContent))return Et(e),!0;if(e.nodeType===ee)return Et(e),!0;if(Ue&&e.nodeType===te&&S(/<[/\w]/g,e.data))return Et(e),!0;if(!Ne[n]||ve[n]){if(!ve[n]&&Dt(n)){if(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n))return!1;if(De.tagNameCheck instanceof Function&&De.tagNameCheck(n))return!1}if(je&&!$e[n]){const t=ae(e)||e.parentNode,n=ie(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=$(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,re(e))}}}return Et(e),!0}return e instanceof O&&!function(e){let t=ae(e);t&&t.tagName||(t={namespaceURI:ot,tagName:"template"});const n=h(e.tagName),o=h(t.tagName);return!!it[e.namespaceURI]&&(e.namespaceURI===tt?t.namespaceURI===nt?"svg"===n:t.namespaceURI===et?"svg"===n&&("annotation-xml"===o||lt[o]):Boolean(Tt[n]):e.namespaceURI===et?t.namespaceURI===nt?"math"===n:t.namespaceURI===tt?"math"===n&&ct[o]:Boolean(yt[n]):e.namespaceURI===nt?!(t.namespaceURI===tt&&!ct[o])&&!(t.namespaceURI===et&&!lt[o])&&!yt[n]&&(st[n]||!Tt[n]):!("application/xhtml+xml"!==ut||!it[e.namespaceURI]))}(e)?(Et(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!S(/<\/no(script|embed|frames)/i,e.innerHTML)?(ke&&e.nodeType===Q&&(t=e.textContent,u([he,ge,Te],(e=>{t=y(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),Rt(de.afterSanitizeElements,e,null),!1):(Et(e),!0)},Ot=function(e,t,n){if(Ge&&("id"===t||"name"===t)&&(n in r||n in dt))return!1;if(xe&&!Le[t]&&S(ye,t));else if(Ce&&S(Ee,t));else if(!we[t]||Le[t]){if(!(Dt(e)&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,e)||De.tagNameCheck instanceof Function&&De.tagNameCheck(e))&&(De.attributeNameCheck instanceof RegExp&&S(De.attributeNameCheck,t)||De.attributeNameCheck instanceof Function&&De.attributeNameCheck(t))||"is"===t&&De.allowCustomizedBuiltInElements&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n)||De.tagNameCheck instanceof Function&&De.tagNameCheck(n))))return!1}else if(Je[t]);else if(S(be,y(n,_e,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==E(n,"data:")||!Ve[e]){if(Ie&&!S(Ae,y(n,_e,"")));else if(n)return!1}else;return!0},Dt=function(e){return"annotation-xml"!==e&&T(e,Se)},vt=function(e){Rt(de.beforeSanitizeAttributes,e,null);const{attributes:t}=e;if(!t||bt(e))return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:we,forceKeepAttr:void 0};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=pt(a),m=c;let f="value"===a?m:A(m);if(n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,Rt(de.uponSanitizeAttribute,e,n),f=n.attrValue,!Ye||"id"!==s&&"name"!==s||(At(a,e),f="user-content-"+f),Ue&&S(/((--!?|])>)|<\/(style|title)/i,f)){At(a,e);continue}if(n.forceKeepAttr)continue;if(!n.keepAttr){At(a,e);continue}if(!Me&&S(/\/>/i,f)){At(a,e);continue}ke&&u([he,ge,Te],(e=>{f=y(f,e," ")}));const d=pt(e.nodeName);if(Ot(d,s,f)){if(le&&"object"==typeof j&&"function"==typeof j.getAttributeType)if(l);else switch(j.getAttributeType(d,s)){case"TrustedHTML":f=le.createHTML(f);break;case"TrustedScriptURL":f=le.createScriptURL(f)}if(f!==m)try{l?e.setAttributeNS(l,a,f):e.setAttribute(a,f),bt(e)?Et(e):p(o.removed)}catch(t){At(a,e)}}else At(a,e)}Rt(de.afterSanitizeAttributes,e,null)},Lt=function e(t){let n=null;const o=St(t);for(Rt(de.beforeSanitizeShadowDOM,t,null);n=o.nextNode();)Rt(de.uponSanitizeShadowNode,n,null),wt(n),vt(n),n.content instanceof s&&e(n.content);Rt(de.afterSanitizeShadowDOM,t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(rt=!e,rt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw b("toString is not a function");if("string"!=typeof(e=e.toString()))throw b("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Pe||gt(t),o.removed=[],"string"==typeof e&&(Xe=!1),Xe){if(e.nodeName){const t=pt(e.nodeName);if(!Ne[t]||ve[t])throw b("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof R)n=_t("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===J&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!ke&&!ze&&-1===e.indexOf("<"))return le&&We?le.createHTML(e):e;if(n=_t(e),!n)return Fe?null:We?ce:""}n&&He&&Et(n.firstChild);const c=St(Xe?e:n);for(;i=c.nextNode();)wt(i),vt(i),i.content instanceof s&&Lt(i.content);if(Xe)return e;if(Fe){if(Be)for(l=me.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(we.shadowroot||we.shadowrootmode)&&(l=fe.call(a,l,!0)),l}let m=ze?n.outerHTML:n.innerHTML;return ze&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&S(K,n.ownerDocument.doctype.name)&&(m="\n"+m),ke&&u([he,ge,Te],(e=>{m=y(m,e," ")})),le&&We?le.createHTML(m):m},o.setConfig=function(){gt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Pe=!0},o.clearConfig=function(){ft=null,Pe=!1},o.isValidAttribute=function(e,t,n){ft||gt({});const o=pt(e),r=pt(t);return Ot(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&f(de[e],t)},o.removeHook=function(e,t){if(void 0!==t){const n=m(de[e],t);return-1===n?void 0:d(de[e],n,1)[0]}return p(de[e])},o.removeHooks=function(e){de[e]=[]},o.removeAllHooks=function(){de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return re})); +//# sourceMappingURL=purify.min.js.map diff --git a/public/svgs/bluesky.svg b/public/svgs/bluesky.svg new file mode 100644 index 000000000..77ebea072 --- /dev/null +++ b/public/svgs/bluesky.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/svgs/chroma.svg b/public/svgs/chroma.svg new file mode 100644 index 000000000..930288fbf --- /dev/null +++ b/public/svgs/chroma.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/public/svgs/drizzle.jpeg b/public/svgs/drizzle.jpeg new file mode 100644 index 000000000..d84ff854b Binary files /dev/null and b/public/svgs/drizzle.jpeg differ diff --git a/public/svgs/elasticsearch.svg b/public/svgs/elasticsearch.svg new file mode 100644 index 000000000..bfc5bfb6a --- /dev/null +++ b/public/svgs/elasticsearch.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/svgs/homebox.svg b/public/svgs/homebox.svg new file mode 100644 index 000000000..08670bbb9 --- /dev/null +++ b/public/svgs/homebox.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/svgs/langfuse.png b/public/svgs/langfuse.png deleted file mode 100644 index 8dec0fe4a..000000000 Binary files a/public/svgs/langfuse.png and /dev/null differ diff --git a/public/svgs/langfuse.svg b/public/svgs/langfuse.svg new file mode 100644 index 000000000..b04e07490 --- /dev/null +++ b/public/svgs/langfuse.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/svgs/librechat.svg b/public/svgs/librechat.svg new file mode 100644 index 000000000..36a536d65 --- /dev/null +++ b/public/svgs/librechat.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/openpanel.svg b/public/svgs/openpanel.svg new file mode 100644 index 000000000..8508fc69e --- /dev/null +++ b/public/svgs/openpanel.svg @@ -0,0 +1 @@ + diff --git a/public/svgs/pihole.svg b/public/svgs/pihole.svg new file mode 100644 index 000000000..a4efefcc8 --- /dev/null +++ b/public/svgs/pihole.svg @@ -0,0 +1 @@ +NewVortex \ No newline at end of file diff --git a/public/svgs/sequin.svg b/public/svgs/sequin.svg new file mode 100644 index 000000000..623bc1159 --- /dev/null +++ b/public/svgs/sequin.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/resources/css/utilities.css b/resources/css/utilities.css index d09d7f49c..694ad61a3 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -6,10 +6,31 @@ @apply hidden!; } +@utility apexcharts-grid-borders { + @apply dark:hidden!; +} + @utility apexcharts-xaxistooltip { @apply hidden!; } +@utility apexcharts-tooltip-custom { + @apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-lg p-3 text-sm; + min-width: 160px; +} + +@utility apexcharts-tooltip-custom-value { + @apply text-neutral-700 dark:text-neutral-300 mb-1; +} + +@utility apexcharts-tooltip-value-bold { + @apply font-bold text-black dark:text-white; +} + +@utility apexcharts-tooltip-custom-title { + @apply text-xs text-neutral-500 dark:text-neutral-400 font-medium; +} + @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:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300; } diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php index 249aa18f9..66a924fb8 100644 --- a/resources/views/auth/forgot-password.blade.php +++ b/resources/views/auth/forgot-password.blade.php @@ -4,7 +4,7 @@ Coolify
      - {{ __('auth.forgot_password') }} + {{ __('auth.forgot_password_heading') }}
      diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 42faf517f..8bd8e81fc 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -23,7 +23,7 @@ required label="{{ __('input.password') }}" /> - {{ __('auth.forgot_password') }}? + {{ __('auth.forgot_password_link') }} @else - {{ __('auth.forgot_password') }}? + {{ __('auth.forgot_password_link') }} @endenv diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php index cf9e9c029..26b1cedf5 100644 --- a/resources/views/components/applications/links.blade.php +++ b/resources/views/components/applications/links.blade.php @@ -8,133 +8,85 @@ data_get($application, 'previews', collect([]))->count() > 0 || data_get($application, 'ports_mappings_array')) && data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true) - @if (data_get($application, 'gitBrancLocation')) - - - Git Repository - - @endif - @if (data_get($application, 'build_pack') === 'dockercompose') - @foreach (collect(json_decode($this->application->docker_compose_domains)) as $fqdn) - @if (data_get($fqdn, 'domain')) - @foreach (explode(',', data_get($fqdn, 'domain')) as $domain) - - - - - - - {{ getFqdnWithoutPort($domain) }} - - @endforeach - @endif - @endforeach - @endif - @if (data_get($application, 'fqdn')) - @foreach (str(data_get($application, 'fqdn'))->explode(',') as $fqdn) - - - - - - - {{ getFqdnWithoutPort($fqdn) }} +
      + @if (data_get($application, 'gitBrancLocation')) + + + Git Repository - @endforeach - @endif - @if (data_get($application, 'previews', collect())->count() > 0) - @if (data_get($application, 'build_pack') === 'dockercompose') - @foreach ($application->previews as $preview) - @foreach (collect(json_decode($preview->docker_compose_domains)) as $fqdn) - @if (data_get($fqdn, 'domain')) - @foreach (explode(',', data_get($fqdn, 'domain')) as $domain) - - - - - - - PR{{ data_get($preview, 'pull_request_id') }} | - {{ getFqdnWithoutPort($domain) }} - - @endforeach - @endif - @endforeach - @endforeach - @else - @foreach (data_get($application, 'previews') as $preview) - @if (data_get($preview, 'fqdn')) - - - - - - - - PR{{ data_get($preview, 'pull_request_id') }} | - {{ data_get($preview, 'fqdn') }} - - @endif - @endforeach @endif - @endif - @if (data_get($application, 'ports_mappings_array')) - @foreach ($application->ports_mappings_array as $port) - @if ($application->destination->server->id === 0) - - - - - - - - Port {{ $port }} - - @else - - - - - - - - {{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }} - - @if (count($application->additional_servers) > 0) - @foreach ($application->additional_servers as $server) - - - - - - - - {{ $server->ip }}:{{ explode(':', $port)[0] }} + @if (data_get($application, 'build_pack') === 'dockercompose') + @foreach (collect(json_decode($this->application->docker_compose_domains)) as $fqdn) + @if (data_get($fqdn, 'domain')) + @foreach (explode(',', data_get($fqdn, 'domain')) as $domain) + + {{ getFqdnWithoutPort($domain) }} @endforeach @endif + @endforeach + @endif + @if (data_get($application, 'fqdn')) + @foreach (str(data_get($application, 'fqdn'))->explode(',') as $fqdn) + + {{ getFqdnWithoutPort($fqdn) }} + + @endforeach + @endif + @if (data_get($application, 'previews', collect())->count() > 0) + @if (data_get($application, 'build_pack') === 'dockercompose') + @foreach ($application->previews as $preview) + @foreach (collect(json_decode($preview->docker_compose_domains)) as $fqdn) + @if (data_get($fqdn, 'domain')) + @foreach (explode(',', data_get($fqdn, 'domain')) as $domain) + + PR{{ data_get($preview, 'pull_request_id') }} + | + {{ getFqdnWithoutPort($domain) }} + + @endforeach + @endif + @endforeach + @endforeach + @else + @foreach (data_get($application, 'previews') as $preview) + @if (data_get($preview, 'fqdn')) + + + PR{{ data_get($preview, 'pull_request_id') }} | + {{ data_get($preview, 'fqdn') }} + + @endif + @endforeach @endif - @endforeach - @endif + @endif + @if (data_get($application, 'ports_mappings_array')) + @foreach ($application->ports_mappings_array as $port) + @if ($application->destination->server->id === 0) + + + Port {{ $port }} + + @else + + + {{ $application->destination->server->ip }}:{{ explode(':', $port)[0] }} + + @if (count($application->additional_servers) > 0) + @foreach ($application->additional_servers as $server) + + + {{ $server->ip }}:{{ explode(':', $port)[0] }} + + @endforeach + @endif + @endif + @endforeach + @endif +
      @else
      No links available
      @endif diff --git a/resources/views/components/domain-conflict-modal.blade.php b/resources/views/components/domain-conflict-modal.blade.php new file mode 100644 index 000000000..218a7ef16 --- /dev/null +++ b/resources/views/components/domain-conflict-modal.blade.php @@ -0,0 +1,91 @@ +@props([ + 'conflicts' => [], + 'showModal' => false, + 'confirmAction' => 'confirmDomainUsage', +]) + +@if ($showModal && count($conflicts) > 0) +
      + +
      +@endif diff --git a/resources/views/components/external-link.blade.php b/resources/views/components/external-link.blade.php index ddf03427f..9e68704cd 100644 --- a/resources/views/components/external-link.blade.php +++ b/resources/views/components/external-link.blade.php @@ -1,7 +1,6 @@ -
      @else @@ -45,7 +46,8 @@ max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}" maxlength="{{ $attributes->get('maxlength') }}" @if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}" - placeholder="{{ $attributes->get('placeholder') }}"> + placeholder="{{ $attributes->get('placeholder') }}" + @if ($autofocus) x-ref="autofocusInput" @endif> @endif @if (!$label && $helper) diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index f5a0ca84a..1c82614a6 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -11,6 +11,7 @@ 'content' => null, 'checkboxes' => [], 'actions' => [], + 'warningMessage' => null, 'confirmWithText' => true, 'confirmationText' => 'Confirm Deletion', 'confirmationLabel' => 'Please confirm the execution of the actions by entering the Name below', @@ -42,7 +43,11 @@ deleteText: '', password: '', actions: @js($actions), - confirmationText: @js(html_entity_decode($confirmationText, ENT_QUOTES, 'UTF-8')), + confirmationText: (() => { + const textarea = document.createElement('textarea'); + textarea.innerHTML = @js($confirmationText); + return textarea.value; + })(), userConfirmationText: '', confirmWithText: @js($confirmWithText && !$disableTwoStepConfirmation), confirmWithPassword: @js($confirmWithPassword && !$disableTwoStepConfirmation), @@ -224,7 +229,7 @@
      The following actions will be performed:
      @@ -257,8 +262,21 @@

      Confirm Actions

      {{ $confirmationLabel }}

      -
      - +
      +
      + + +
    2. -
    3. - - - - - - - Terminal - -
    4. + @can('canAccessTerminal') +
    5. + + + + + + + Terminal + +
    6. + @endcan
    7. - @if (isCloud()) + @if (isCloud() && auth()->user()->isAdmin())
    8. @foreach ($links as $link) - - - - - - {{ $link }} + {{ $link }} @endforeach diff --git a/resources/views/components/toast.blade.php b/resources/views/components/toast.blade.php index 7d418a8ab..cec1e6c3f 100644 --- a/resources/views/components/toast.blade.php +++ b/resources/views/components/toast.blade.php @@ -6,7 +6,7 @@ popToast(custom) { let html = ''; if (typeof custom != 'undefined') { - html = custom; + html = window.sanitizeHTML(custom); } toast(this.title, { description: this.description, type: this.type, position: this.position, html: html }) } @@ -96,27 +96,27 @@ topToast.style.top = '0px'; } } - + let bottomPositionOfFirstToast = this.getBottomPositionOfElement(topToast); - + if (this.toasts.length == 1) return; let middleToast = document.getElementById(this.toasts[1].id); middleToast.style.zIndex = 90; - + if (this.expanded) { middleToastPosition = topToast.getBoundingClientRect().height + this.paddingBetweenToasts + 'px'; - + if (this.position.includes('bottom')) { middleToast.style.top = 'auto'; middleToast.style.bottom = middleToastPosition; } else { middleToast.style.top = middleToastPosition; } - + middleToast.style.scale = '100%'; middleToast.style.transform = 'translateY(0px)'; - + } else { middleToast.style.scale = '94%'; if (this.position.includes('bottom')) { @@ -126,8 +126,8 @@ middleToast.style.transform = 'translateY(16px)'; } } - - + + if (this.toasts.length == 2) return; let bottomToast = document.getElementById(this.toasts[2].id); bottomToast.style.zIndex = 80; @@ -136,14 +136,14 @@ this.paddingBetweenToasts + middleToast.getBoundingClientRect().height + this.paddingBetweenToasts + 'px'; - + if (this.position.includes('bottom')) { bottomToast.style.top = 'auto'; bottomToast.style.bottom = bottomToastPosition; } else { bottomToast.style.top = bottomToastPosition; } - + bottomToast.style.scale = '100%'; bottomToast.style.transform = 'translateY(0px)'; } else { @@ -155,9 +155,9 @@ bottomToast.style.transform = 'translateY(32px)'; } } - - - + + + if (this.toasts.length == 3) return; let burnToast = document.getElementById(this.toasts[3].id); burnToast.style.zIndex = 70; @@ -168,14 +168,14 @@ this.paddingBetweenToasts + bottomToast.getBoundingClientRect().height + this.paddingBetweenToasts + 'px'; - + if (this.position.includes('bottom')) { burnToast.style.top = 'auto'; burnToast.style.bottom = burnToastPosition; } else { burnToast.style.top = burnToastPosition; } - + burnToast.style.scale = '100%'; burnToast.style.transform = 'translateY(0px)'; } else { @@ -183,40 +183,40 @@ this.alignBottom(topToast, burnToast); burnToast.style.transform = 'translateY(48px)'; } - + burnToast.firstElementChild.classList.remove('opacity-100'); burnToast.firstElementChild.classList.add('opacity-0'); - + let that = this; // Burn 🔥 (remove) last toast setTimeout(function() { that.toasts.pop(); }, 300); - + if (this.position.includes('bottom')) { middleToast.style.top = 'auto'; } - + return; }, alignBottom(element1, element2) { // Get the top position and height of the first element let top1 = element1.offsetTop; let height1 = element1.offsetHeight; - + // Get the height of the second element let height2 = element2.offsetHeight; - + // Calculate the top position for the second element let top2 = top1 + (height1 - height2); - + // Apply the calculated top position to the second element element2.style.top = top2 + 'px'; }, alignTop(element1, element2) { // Get the top position of the first element let top1 = element1.offsetTop; - + // Apply the same top position to the second element element2.style.top = top1 + 'px'; }, @@ -244,13 +244,13 @@ $el.style.height = '0px'; return; } - + lastToast = this.toasts[this.toasts.length - 1]; lastToastRectangle = document.getElementById(lastToast.id).getBoundingClientRect(); - + firstToast = this.toasts[0]; firstToastRectangle = document.getElementById(firstToast.id).getBoundingClientRect(); - + if (this.toastsHovered) { if (this.position.includes('bottom')) { $el.style.height = ((firstToastRectangle.top + firstToastRectangle.height) - lastToastRectangle.top) + 'px'; @@ -276,13 +276,16 @@ if(event.detail.position){ position = event.detail.position; } + // Sanitize HTML content to prevent XSS + let sanitizedHtml = event.detail.html ? window.sanitizeHTML(event.detail.html) : ''; + toasts.unshift({ id: 'toast-' + Math.random().toString(16).slice(2), show: false, message: event.detail.message, description: event.detail.description, type: event.detail.type, - html: event.detail.html + html: sanitizedHtml }); " @mouseenter="toastsHovered=true;" @mouseleave="toastsHovered=false" x-init="if (layout == 'expanded') { @@ -356,9 +359,9 @@ }, 2000) } }); - + setTimeout(function() { - + setTimeout(function() { if (position.includes('bottom')) { $el.firstElementChild.classList.remove('opacity-0', 'translate-y-full'); @@ -366,13 +369,13 @@ $el.firstElementChild.classList.remove('opacity-0', '-translate-y-full'); } $el.firstElementChild.classList.add('opacity-100', 'translate-y-0'); - + setTimeout(function() { stackToasts(); }, 10); }, 5); }, 50); - + this.timeout = setTimeout(function() { setTimeout(function() { $el.firstElementChild.classList.remove('opacity-100'); @@ -421,16 +424,16 @@ d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9996 7C12.5519 7 12.9996 7.44772 12.9996 8V12C12.9996 12.5523 12.5519 13 11.9996 13C11.4474 13 10.9996 12.5523 10.9996 12V8C10.9996 7.44772 11.4474 7 11.9996 7ZM12.001 14.99C11.4488 14.9892 11.0004 15.4363 10.9997 15.9886L10.9996 15.9986C10.9989 16.5509 11.446 16.9992 11.9982 17C12.5505 17.0008 12.9989 16.5537 12.9996 16.0014L12.9996 15.9914C13.0004 15.4391 12.5533 14.9908 12.001 14.99Z" fill="currentColor"> -

      +

      + x-html="window.sanitizeHTML(toast.description)"> +You have requested to change your email address to: {{ $newEmail }} + +Please use the following verification code to confirm this change: + +Verification Code: {{ $verificationCode }} + +This code is valid for {{ $expiryMinutes }} minutes. + +If you did not request this change, please ignore this email and your email address will remain unchanged. + \ No newline at end of file diff --git a/resources/views/errors/400.blade.php b/resources/views/errors/400.blade.php index 2276f5a62..4b5956142 100644 --- a/resources/views/errors/400.blade.php +++ b/resources/views/errors/400.blade.php @@ -6,13 +6,17 @@ @if ($exception->getMessage())

      {{ $exception->getMessage() }}

      @else -

      The request could not be understood by the server due to +

      The request could not be understood by the + server due to malformed syntax.

      @endif -
      - - Go back home +
      + + Go back + + + Dashboard Contact support diff --git a/resources/views/errors/401.blade.php b/resources/views/errors/401.blade.php index e0a44aed8..95449c141 100644 --- a/resources/views/errors/401.blade.php +++ b/resources/views/errors/401.blade.php @@ -3,11 +3,14 @@

      401

      You shall not pass!

      -

      You don't have permission to access this page. +

      You don't have permission to access this page.

      -
      - - Go back home +
      + + Go back + + + Dashboard Contact support diff --git a/resources/views/errors/402.blade.php b/resources/views/errors/402.blade.php index 9758dec2d..6534615df 100644 --- a/resources/views/errors/402.blade.php +++ b/resources/views/errors/402.blade.php @@ -4,9 +4,12 @@

      402

      Payment required.

      -
      - - Go back home +
      + + Go back + + + Dashboard Contact support diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php index f54a2866a..50317700d 100644 --- a/resources/views/errors/403.blade.php +++ b/resources/views/errors/403.blade.php @@ -3,11 +3,14 @@

      403

      You shall not pass!

      -

      You don't have permission to access this page. +

      You don't have permission to access this page.

      -
      - - Go back home +
      + + Go back + + + Dashboard Contact support diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php index 569488d19..67fb0f0f1 100644 --- a/resources/views/errors/404.blade.php +++ b/resources/views/errors/404.blade.php @@ -3,12 +3,15 @@

      404

      How did you get here?

      -

      Sorry, we couldn’t find the page you’re looking +

      Sorry, we couldn’t find the page you’re looking for.

      -
      - - Go back home +
      + + Go back + + + Dashboard Contact support diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php index 723ba9f55..5367898f0 100644 --- a/resources/views/errors/419.blade.php +++ b/resources/views/errors/419.blade.php @@ -3,12 +3,15 @@

      419

      This page is definitely old, not like you!

      -

      Sorry, we couldn’t find the page you’re looking +

      Sorry, we couldn’t find the page you’re looking for.

      -
      - - Go back home +
      + + Go back + + + Dashboard Contact support diff --git a/resources/views/errors/429.blade.php b/resources/views/errors/429.blade.php index 443244351..36c8e95f6 100644 --- a/resources/views/errors/429.blade.php +++ b/resources/views/errors/429.blade.php @@ -3,12 +3,16 @@

      429

      Woah, slow down there!

      -

      You're making too many requests. Please wait a few +

      You're making too many requests. Please wait a + few seconds before trying again.

      -
      - - Go back home +
      + + Go back + + + Dashboard Contact support diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php index cc672a324..149be2685 100644 --- a/resources/views/errors/500.blade.php +++ b/resources/views/errors/500.blade.php @@ -3,18 +3,22 @@

      500

      Wait, this is not cool...

      -

      There has been an error with the following error message:

      +

      There has been an error with the following + error message:

      @if ($exception->getMessage() !== '')
      {!! Purify::clean($exception->getMessage()) !!}
      @endif -
      - - Go back home + diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php index 668ea5e3a..7db859624 100644 --- a/resources/views/errors/503.blade.php +++ b/resources/views/errors/503.blade.php @@ -3,10 +3,17 @@

      503

      We are working on serious things.

      -

      Service Unavailable. Be right back. Thanks for your +

      Service Unavailable. Be right back. Thanks for + your patience.

      -
      +
      + + Go back + + + Dashboard + Contact support diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index d9975c975..c074412d3 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -35,9 +35,9 @@ @endphp {{ $name }}{{ $title ?? 'Coolify' }} @env('local') - -@else - + + @else + @endenv @vite(['resources/js/app.js', 'resources/css/app.css']) @@ -54,6 +54,7 @@ + @endauth @section('body') @@ -61,6 +62,67 @@ -

      Memory (MB)

      +

      Memory Usage

      +
      @endif @endif +
      diff --git a/resources/views/livewire/project/shared/resource-limits.blade.php b/resources/views/livewire/project/shared/resource-limits.blade.php index 6c84335de..2aa2fd0af 100644 --- a/resources/views/livewire/project/shared/resource-limits.blade.php +++ b/resources/views/livewire/project/shared/resource-limits.blade.php @@ -2,40 +2,39 @@

      Resource Limits

      - Save + Save
      Limit your container resources by CPU & memory.

      Limit CPUs

      - - -

      Limit Memory

      - -
      - - -
      diff --git a/resources/views/livewire/project/shared/resource-operations.blade.php b/resources/views/livewire/project/shared/resource-operations.blade.php index f23c6eb4e..aa8f536ce 100644 --- a/resources/views/livewire/project/shared/resource-operations.blade.php +++ b/resources/views/livewire/project/shared/resource-operations.blade.php @@ -1,30 +1,41 @@

      Resource Operations

      You can easily make different kind of operations on this resource.
      -

      Clone

      +

      Clone

      To another project / environment on a different / same server.
      - @foreach ($servers->sortBy('id') as $server) -
      Server: {{ $server->name }}
      - @foreach ($server->destinations() as $destination) - - -
      -
      -
      Network
      -
      {{ $destination->name }}
      + @can('update', $resource) + @foreach ($servers->sortBy('id') as $server) +
      Server: {{ $server->name }}
      + @foreach ($server->destinations() as $destination) + + +
      +
      +
      Network
      +
      {{ $destination->name }}
      +
      -
      - - + + + @endforeach @endforeach - @endforeach + @else +
      +
      + Access Restricted: You don't have permission to clone resources. Contact your team + administrator to request access. +
      +
      + @endcan

      Move

      @@ -36,28 +47,38 @@ {{ $resource->environment->name }} environment.
      - @forelse ($projects as $project) -
      Project: {{ $project->name }}
      + @can('update', $resource) + @forelse ($projects as $project) +
      Project: {{ $project->name }}
      - @foreach ($project->environments as $environment) - - -
      -
      -
      Environment
      -
      {{ $environment->name }}
      + @foreach ($project->environments as $environment) + + +
      +
      +
      Environment
      +
      {{ $environment->name }}
      +
      -
      - - - @endforeach - @empty -
      No projects found to move to
      - @endforelse + + + @endforeach + @empty +
      No projects found to move to
      + @endforelse + @else +
      +
      + Access Restricted: You don't have permission to move resources between projects or + environments. Contact your team administrator to request access. +
      +
      + @endcan
      diff --git a/resources/views/livewire/project/shared/scheduled-task/all.blade.php b/resources/views/livewire/project/shared/scheduled-task/all.blade.php index 8a2ec4d7a..fb6985094 100644 --- a/resources/views/livewire/project/shared/scheduled-task/all.blade.php +++ b/resources/views/livewire/project/shared/scheduled-task/all.blade.php @@ -1,13 +1,15 @@

      Scheduled Tasks

      - - @if ($resource->type() == 'application') - - @elseif ($resource->type() == 'service') - - @endif - + @can('update', $resource) + + @if ($resource->type() == 'application') + + @elseif ($resource->type() == 'service') + + @endif + + @endcan
      @forelse($resource->scheduled_tasks as $task) diff --git a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php index 8f0f309c6..2ed3adc0c 100644 --- a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php +++ b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php @@ -14,7 +14,7 @@ }"> @forelse($executions as $execution) data_get($execution, 'id') == $selectedKey, 'border-blue-500/50 border-dashed' => data_get($execution, 'status') === 'running', 'border-error' => data_get($execution, 'status') === 'failed', @@ -67,18 +67,22 @@ @endif @if ($this->logLines->isNotEmpty())
      -
      +                        
      +
       @foreach ($this->logLines as $line)
       {{ $line }}
       @endforeach
       
      -
      +
      +
      @if ($this->hasMoreLogs()) Load More + + Load All + @endif -
      @else diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index e357b4f94..d62362562 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -3,11 +3,10 @@ @foreach ($resource->persistentStorages as $storage) @if ($resource->type() === 'service') + :resource="$resource" :isFirst="$storage->id === $this->firstStorageId" isService='true' /> @else + :resource="$resource" :isFirst="$storage->id === $this->firstStorageId" startedAt="{{ data_get($resource, 'started_at') }}" /> @endif @endforeach
      diff --git a/resources/views/livewire/project/shared/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index 4ad5636ec..569df0c4b 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -9,7 +9,7 @@ @else - @endif @if ($isService || $startedAt) @@ -19,13 +19,11 @@ @else - - - Update - @endif
      @else @@ -36,32 +34,50 @@
      @endif @else - @if ($isFirst) -
      - - - + @can('update', $resource) + @if ($isFirst) +
      + + + +
      + @else +
      + + + +
      + @endif +
      + + Update + +
      @else -
      - - - -
      - @endif -
      - - Update - - -
      + @if ($isFirst) +
      + + + +
      + @else +
      + + + +
      + @endif + @endcan @endif
      diff --git a/resources/views/livewire/project/shared/tags.blade.php b/resources/views/livewire/project/shared/tags.blade.php index 4ceb475a6..2c75deab9 100644 --- a/resources/views/livewire/project/shared/tags.blade.php +++ b/resources/views/livewire/project/shared/tags.blade.php @@ -1,37 +1,50 @@

      Tags

      -
      -
      - + @can('update', $resource) + +
      + +
      + Add + + @else +
      +
      + Access Restricted: You don't have permission to manage tags. Contact your team + administrator to request access. +
      - Add - + @endcan @if (data_get($this->resource, 'tags') && count(data_get($this->resource, 'tags')) > 0)

      Assigned Tags

      @foreach (data_get($this->resource, 'tags') as $tagId => $tag)
      {{ $tag->name }} - - - - + @can('update', $resource) + + + + + @endcan
      @endforeach
      @endif - @if (count($filteredTags) > 0) -

      Existing Tags

      -
      Click to add quickly
      -
      - @foreach ($filteredTags as $tag) - - {{ $tag->name }} - @endforeach -
      - @endif + @can('update', $resource) + @if (count($filteredTags) > 0) +

      Existing Tags

      +
      Click to add quickly
      +
      + @foreach ($filteredTags as $tag) + + {{ $tag->name }} + @endforeach +
      + @endif + @endcan
      diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php index 988ecd662..c46c5f316 100644 --- a/resources/views/livewire/project/shared/terminal.blade.php +++ b/resources/views/livewire/project/shared/terminal.blade.php @@ -9,7 +9,8 @@

      Terminal Not Available

      -

      No shell (bash/sh) is available in this container. Please +

      No shell (bash/sh) is available in this container. + Please ensure either bash or sh is installed to use the terminal.

      diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php index 64a2ece51..f3403a402 100644 --- a/resources/views/livewire/project/shared/webhooks.blade.php +++ b/resources/views/livewire/project/shared/webhooks.blade.php @@ -17,10 +17,15 @@
      - - + @can('update', $resource) + + @else + + @endcan
      Webhook Configuration on GitHub @@ -29,23 +34,43 @@
      - + @can('update', $resource) + + @else + + @endcan
      - + @can('update', $resource) + + @else + + @endcan
      - + @can('update', $resource) + + @else + + @endcan
      - Save + @can('update', $resource) + Save + @endcan @else You are using an official Git App. You do not need manual webhooks. diff --git a/resources/views/livewire/project/show.blade.php b/resources/views/livewire/project/show.blade.php index 3bf427561..3d034b8f3 100644 --- a/resources/views/livewire/project/show.blade.php +++ b/resources/views/livewire/project/show.blade.php @@ -4,15 +4,19 @@

      Environments

      - -
      - - - Save - - -
      - + @can('update', $project) + +
      + + + Save + + +
      + @endcan + @can('delete', $project) + + @endcan
      {{ $project->name }}.
      @@ -25,12 +29,14 @@
      {{ $environment->description }}
      - + @can('update', $project) + + @endcan
      @empty diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index eaf7a439d..bf6bcf76c 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -12,44 +12,58 @@
      Tokens are created with the current team as scope.

      New Token

      -
      -
      - - Create -
      -
      - Permissions - : -
      - @if ($permissions) - @foreach ($permissions as $permission) -
      {{ $permission }}
      - @endforeach + @can('create', App\Models\PersonalAccessToken::class) + +
      + + Create +
      +
      + Permissions + : +
      + @if ($permissions) + @foreach ($permissions as $permission) +
      {{ $permission }}
      + @endforeach + @endif +
      +
      + +

      Token Permissions

      +
      + @if ($canUseRootPermissions) + + @else + + @endif + + @if (!in_array('root', $permissions)) + @if ($canUseWritePermissions) + + @else + + @endif + + + + @endif
      -
      - -

      Token Permissions

      -
      - - @if (!in_array('root', $permissions)) - - - - + @if (in_array('root', $permissions)) +
      Root access, be careful!
      @endif -
      - @if (in_array('root', $permissions)) -
      Root access, be careful!
      - @endif - + + @endcan @if (session()->has('token'))
      Please copy this token now. For your security, it won't be shown again. @@ -72,15 +86,17 @@ @endif
      - + @if (auth()->id() === $token->tokenable_id) + + @endif
      @empty
      diff --git a/resources/views/livewire/security/private-key/create.blade.php b/resources/views/livewire/security/private-key/create.blade.php index 132c0e9ad..4294823e0 100644 --- a/resources/views/livewire/security/private-key/create.blade.php +++ b/resources/views/livewire/security/private-key/create.blade.php @@ -1,5 +1,5 @@
      -
      +
      Private Keys are used to connect to your servers without passwords.
      You should not use passphrase protected keys.
      diff --git a/resources/views/livewire/security/private-key/index.blade.php b/resources/views/livewire/security/private-key/index.blade.php index f40b91f43..47cfc9b1e 100644 --- a/resources/views/livewire/security/private-key/index.blade.php +++ b/resources/views/livewire/security/private-key/index.blade.php @@ -2,11 +2,15 @@

      Private Keys

      - - - - + @can('create', App\Models\PrivateKey::class) + + + + @endcan + @can('create', App\Models\PrivateKey::class) + + @endcan
      @forelse ($privateKeys as $key) diff --git a/resources/views/livewire/security/private-key/show.blade.php b/resources/views/livewire/security/private-key/show.blade.php index c381b8f2a..8668cfd34 100644 --- a/resources/views/livewire/security/private-key/show.blade.php +++ b/resources/views/livewire/security/private-key/show.blade.php @@ -7,32 +7,34 @@

      Private Key

      - + Save @if (data_get($private_key, 'id') > 0) - + @can('delete', $private_key) + + @endcan @endif
      - - + +
      Public Key
      - +
      Private Key *
      @endif
      -
      - +
      diff --git a/resources/views/livewire/server/advanced.blade.php b/resources/views/livewire/server/advanced.blade.php index 98ab15534..308a9eed2 100644 --- a/resources/views/livewire/server/advanced.blade.php +++ b/resources/views/livewire/server/advanced.blade.php @@ -9,7 +9,7 @@

      Advanced

      - Save + Save
      Advanced configuration for your server.
      @@ -59,10 +59,10 @@
      - -
      @@ -71,9 +71,11 @@

      Builds

      - -
      diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php index 66262614c..f11bd732e 100644 --- a/resources/views/livewire/server/ca-certificate/show.blade.php +++ b/resources/views/livewire/server/ca-certificate/show.blade.php @@ -8,28 +8,30 @@

      CA Certificate

      -
      - - - - -
      + @can('update', $server) +
      + + + + +
      + @endcan
      @@ -63,9 +65,11 @@ @endif
      - - {{ $showCertificate ? 'Hide' : 'Show' }} - + @can('view', $server) + + {{ $showCertificate ? 'Hide' : 'Show' }} + + @endcan
      @if ($showCertificate)